From d081729223c3a3e02de2eaee3c4773485ff329fb Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Mon, 5 Feb 2024 19:09:52 -0300 Subject: [PATCH 1/3] feat: agent dashboard header layout (#3592) * add initial dashboard code * implement banner for messages in header * commit rest of files * fix build and set logo static --- agent/ui/dashboard/components/header.go | 180 ++++++++++++++++++ .../ui/dashboard/components/message_banner.go | 43 +++++ .../dashboard/components/render_scheduler.go | 19 ++ agent/ui/dashboard/dashboard.go | 43 +++++ agent/ui/dashboard/events/errors.go | 8 + agent/ui/dashboard/events/events.go | 5 + agent/ui/dashboard/main/main.go | 11 ++ agent/ui/dashboard/pages/test_runs_page.go | 28 +++ agent/ui/dashboard/router.go | 23 +++ agent/ui/dashboard/sensors/sensor.go | 59 ++++++ agent/ui/dashboard/styles/styles.go | 21 ++ go.mod | 4 + go.sum | 16 ++ 13 files changed, 460 insertions(+) create mode 100644 agent/ui/dashboard/components/header.go create mode 100644 agent/ui/dashboard/components/message_banner.go create mode 100644 agent/ui/dashboard/components/render_scheduler.go create mode 100644 agent/ui/dashboard/dashboard.go create mode 100644 agent/ui/dashboard/events/errors.go create mode 100644 agent/ui/dashboard/events/events.go create mode 100644 agent/ui/dashboard/main/main.go create mode 100644 agent/ui/dashboard/pages/test_runs_page.go create mode 100644 agent/ui/dashboard/router.go create mode 100644 agent/ui/dashboard/sensors/sensor.go create mode 100644 agent/ui/dashboard/styles/styles.go diff --git a/agent/ui/dashboard/components/header.go b/agent/ui/dashboard/components/header.go new file mode 100644 index 0000000000..96efb46e03 --- /dev/null +++ b/agent/ui/dashboard/components/header.go @@ -0,0 +1,180 @@ +package components + +import ( + "fmt" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "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 + uptimeTextView *tview.TableCell + testRunsTextView *tview.TableCell + tracesTextView *tview.TableCell + spansTextView *tview.TableCell +} + +func NewHeader(renderScheduler RenderScheduler, sensor sensors.Sensor) *Header { + h := &Header{ + Flex: tview.NewFlex(), + renderScheduler: renderScheduler, + sensor: sensor, + messageBanner: NewMessageBanner(renderScheduler), + } + + h.draw() + + return h +} + +func (h *Header) draw() { + h.Clear() + + flex := tview.NewFlex() + + flex.SetDirection(tview.FlexColumn). + AddItem(h.getEnvironmentInformationTable(), 0, 4, true). + AddItem(h.getMetricsTable(), 0, 2, true). + AddItem(h.getTracetestLogo(), 0, 2, true) + + h.Flex.SetDirection(tview.FlexRow).AddItem(h.messageBanner, 0, 0, true).AddItem(flex, 0, 8, true) + + h.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 's': + h.messageBanner.SetMessage("Now you see me :D", events.Error) + h.showMessageBanner() + case 'w': + h.messageBanner.SetMessage("This is a warning! :D", events.Warning) + h.showMessageBanner() + case 'h': + h.messageBanner.SetMessage("", events.Error) + h.hideMessageBanner() + } + + return event + }) + + h.setupSensors() +} + +func (h *Header) onDataChange() { + h.renderScheduler.Render(func() { + h.uptimeTextView.SetText(h.data.Metrics.Uptime.String()) + h.testRunsTextView.SetText(fmt.Sprintf("%d", h.data.Metrics.TestRuns)) + h.tracesTextView.SetText(fmt.Sprintf("%d", h.data.Metrics.Traces)) + h.spansTextView.SetText(fmt.Sprintf("%d", h.data.Metrics.Spans)) + + // if text := h.data.Message.Text; text == "" { + // h.hideMessageBanner() + // } else { + // h.messageBanner.SetText(text) + // h.showMessageBanner() + // } + }) +} + +func (h *Header) getEnvironmentInformationTable() tview.Primitive { + table := tview.NewTable() + table.SetBackgroundColor(styles.HeaderBackgroundColor) + table.SetCell(0, 0, tview.NewTableCell("Organization: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(0, 1, tview.NewTableCell("my-company").SetStyle(styles.MetricValueStyle)) + table.SetCell(1, 0, tview.NewTableCell("Environment: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(1, 1, tview.NewTableCell("steve-dev").SetStyle(styles.MetricValueStyle)) + table.SetCell(2, 0, tview.NewTableCell("Last Tracing Backend: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(2, 1, tview.NewTableCell("Jaeger").SetStyle(styles.MetricValueStyle)) + table.SetCell(3, 0, tview.NewTableCell("Version: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(3, 1, tview.NewTableCell("v0.15.5").SetStyle(styles.MetricValueStyle)) + table.SetBorderPadding(1, 1, 2, 1) + return table +} + +func (h *Header) getMetricsTable() tview.Primitive { + h.uptimeTextView = tview.NewTableCell("0s").SetStyle(styles.MetricValueStyle) + h.testRunsTextView = tview.NewTableCell("15").SetStyle(styles.MetricValueStyle) + h.tracesTextView = tview.NewTableCell("15").SetStyle(styles.MetricValueStyle) + h.spansTextView = tview.NewTableCell("61").SetStyle(styles.MetricValueStyle) + table := tview.NewTable() + table.SetBackgroundColor(styles.HeaderBackgroundColor) + table.SetCell(0, 0, tview.NewTableCell("Uptime: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(0, 1, h.uptimeTextView) + table.SetCell(1, 0, tview.NewTableCell("Test runs: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(1, 1, h.testRunsTextView) + table.SetCell(2, 0, tview.NewTableCell("Traces: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(2, 1, h.tracesTextView) + table.SetCell(3, 0, tview.NewTableCell("Spans: ").SetStyle(styles.MetricNameStyle)) + table.SetCell(3, 1, h.spansTextView) + table.SetBorderPadding(1, 1, 1, 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) +} + +const logo = ` _______ _ _ +|__ __| | | | | + | |_ __ __ _ ___ ___| |_ ___ ___| |_ + | | '__/ _\ |/ __/ _ | __/ _ / __| __| + | | | | (_| | (_| __| || __\__ | |_ + |_|_| \__,_|\___\___|\__\___|___/\__| + + ` + +func (h *Header) getTracetestLogo() tview.Primitive { + textView := tview.NewTextView().SetTextColor(styles.HeaderLogoColor) + textView.SetBackgroundColor(styles.HeaderBackgroundColor) + textView.SetText(logo) + textView.SetWrap(false).SetWordWrap(false) + + return textView +} + +func (h *Header) setupSensors() { + h.sensor.On(events.UptimeChanged, func(e sensors.Event) { + var uptime time.Duration + e.Unmarshal(&uptime) + + h.data.Metrics.Uptime = uptime + 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/dashboard.go b/agent/ui/dashboard/dashboard.go new file mode 100644 index 0000000000..4538ab2a3e --- /dev/null +++ b/agent/ui/dashboard/dashboard.go @@ -0,0 +1,43 @@ +package dashboard + +import ( + "context" + "fmt" + "time" + + "github.com/kubeshop/tracetest/agent/ui/dashboard/components" + "github.com/kubeshop/tracetest/agent/ui/dashboard/events" + "github.com/kubeshop/tracetest/agent/ui/dashboard/pages" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/rivo/tview" +) + +type Dashboard struct{} + +func startUptimeCounter(sensor sensors.Sensor) { + ticker := time.NewTicker(time.Second) + start := time.Now() + go func() { + for { + <-ticker.C + sensor.Emit(events.UptimeChanged, time.Since(start).Round(time.Second)) + } + }() +} + +func StartDashboard(ctx context.Context) error { + app := tview.NewApplication() + renderScheduler := components.NewRenderScheduler(app) + sensor := sensors.NewSensor() + + startUptimeCounter(sensor) + + router := NewRouter() + router.AddAndSwitchToPage("home", pages.NewTestRunPage(renderScheduler, sensor)) + + 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..e2da936239 --- /dev/null +++ b/agent/ui/dashboard/events/events.go @@ -0,0 +1,5 @@ +package events + +var ( + UptimeChanged = "uptime_changed" +) diff --git a/agent/ui/dashboard/main/main.go b/agent/ui/dashboard/main/main.go new file mode 100644 index 0000000000..56f488b56c --- /dev/null +++ b/agent/ui/dashboard/main/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "context" + + "github.com/kubeshop/tracetest/agent/ui/dashboard" +) + +func main() { + dashboard.StartDashboard(context.Background()) +} 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..b95745eb5c --- /dev/null +++ b/agent/ui/dashboard/pages/test_runs_page.go @@ -0,0 +1,28 @@ +package pages + +import ( + "github.com/kubeshop/tracetest/agent/ui/dashboard/components" + "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" + "github.com/rivo/tview" +) + +type TestRunPage struct { + *tview.Grid + + renderScheduler components.RenderScheduler +} + +func NewTestRunPage(renderScheduler components.RenderScheduler, sensor sensors.Sensor) *TestRunPage { + p := &TestRunPage{ + Grid: tview.NewGrid(), + renderScheduler: renderScheduler, + } + + p.Grid. + SetRows(10, 90). + SetColumns(30, 0, 30). + AddItem(components.NewHeader(renderScheduler, sensor), 0, 0, 1, 3, 0, 0, true). + AddItem(tview.NewBox(), 1, 0, 1, 3, 0, 0, true) + + return p +} 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..ca1942e309 --- /dev/null +++ b/agent/ui/dashboard/sensors/sensor.go @@ -0,0 +1,59 @@ +package sensors + +import ( + "fmt" + + "github.com/fluidtruck/deepcopy" +) + +type Sensor interface { + On(string, func(Event)) + Emit(string, interface{}) +} + +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) +} + +func NewSensor() Sensor { + return &sensor{ + listeners: make(map[string][]func(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) + slice = append(slice, cb) + } + r.listeners[eventName] = append(slice, cb) +} + +func (r *sensor) Emit(eventName string, event interface{}) { + listeners := r.listeners[eventName] + e := Event{ + Name: eventName, + data: event, + } + + 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..320a14ef5b --- /dev/null +++ b/agent/ui/dashboard/styles/styles.go @@ -0,0 +1,21 @@ +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) + + MetricNameStyle = tcell.Style{}. + Foreground(tcell.NewRGBColor(253, 166, 34)). + Bold(true) + + MetricValueStyle = tcell.Style{}. + Foreground(tcell.NewRGBColor(255, 255, 255)). + Bold(true) +) diff --git a/go.mod b/go.mod index e14a7e774e..af580deb6a 100644 --- a/go.mod +++ b/go.mod @@ -113,6 +113,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 @@ -141,6 +143,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 @@ -160,6 +163,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 20d1945242..0a09635cc5 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= @@ -1291,6 +1297,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= @@ -1338,6 +1346,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= @@ -1645,8 +1654,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= @@ -2399,6 +2411,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= @@ -2408,6 +2422,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= @@ -2424,6 +2439,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= From 7b1cfeede14760fc0577145e7e3a11173926e5ef Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Fri, 9 Feb 2024 17:17:39 -0300 Subject: [PATCH 2/3] feat: agent UI test run list (#3619) * adapt layout to match @olha23's design * add test run list * docs: comment some layout explanation --- agent/ui/dashboard/components/header.go | 99 +++++++------------ .../ui/dashboard/components/test_run_list.go | 69 +++++++++++++ agent/ui/dashboard/dashboard.go | 12 ++- agent/ui/dashboard/events/events.go | 4 +- agent/ui/dashboard/main/main.go | 6 +- agent/ui/dashboard/models/test_runs.go | 13 +++ agent/ui/dashboard/pages/test_runs_page.go | 64 +++++++++++- agent/ui/dashboard/sensors/sensor.go | 1 - agent/ui/dashboard/styles/styles.go | 11 ++- 9 files changed, 204 insertions(+), 75 deletions(-) create mode 100644 agent/ui/dashboard/components/test_run_list.go create mode 100644 agent/ui/dashboard/models/test_runs.go diff --git a/agent/ui/dashboard/components/header.go b/agent/ui/dashboard/components/header.go index 96efb46e03..476aa43dc9 100644 --- a/agent/ui/dashboard/components/header.go +++ b/agent/ui/dashboard/components/header.go @@ -4,7 +4,6 @@ import ( "fmt" "time" - "github.com/gdamore/tcell/v2" "github.com/kubeshop/tracetest/agent/ui/dashboard/events" "github.com/kubeshop/tracetest/agent/ui/dashboard/sensors" "github.com/kubeshop/tracetest/agent/ui/dashboard/styles" @@ -42,11 +41,11 @@ type Header struct { sensor sensors.Sensor data HeaderData - messageBanner *MessageBanner - uptimeTextView *tview.TableCell - testRunsTextView *tview.TableCell - tracesTextView *tview.TableCell - spansTextView *tview.TableCell + messageBanner *MessageBanner + uptimeTableCell *tview.TableCell + runsTableCell *tview.TableCell + tracesTableCell *tview.TableCell + spansTableCell *tview.TableCell } func NewHeader(renderScheduler RenderScheduler, sensor sensors.Sensor) *Header { @@ -55,6 +54,10 @@ func NewHeader(renderScheduler RenderScheduler, sensor sensors.Sensor) *Header { renderScheduler: renderScheduler, sensor: sensor, messageBanner: NewMessageBanner(renderScheduler), + 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() @@ -65,53 +68,36 @@ func NewHeader(renderScheduler RenderScheduler, sensor sensors.Sensor) *Header { 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, 4, true). - AddItem(h.getMetricsTable(), 0, 2, true). - AddItem(h.getTracetestLogo(), 0, 2, true) - - h.Flex.SetDirection(tview.FlexRow).AddItem(h.messageBanner, 0, 0, true).AddItem(flex, 0, 8, true) - - h.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - switch event.Rune() { - case 's': - h.messageBanner.SetMessage("Now you see me :D", events.Error) - h.showMessageBanner() - case 'w': - h.messageBanner.SetMessage("This is a warning! :D", events.Warning) - h.showMessageBanner() - case 'h': - h.messageBanner.SetMessage("", events.Error) - h.hideMessageBanner() - } - - return event - }) + 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.uptimeTextView.SetText(h.data.Metrics.Uptime.String()) - h.testRunsTextView.SetText(fmt.Sprintf("%d", h.data.Metrics.TestRuns)) - h.tracesTextView.SetText(fmt.Sprintf("%d", h.data.Metrics.Traces)) - h.spansTextView.SetText(fmt.Sprintf("%d", h.data.Metrics.Spans)) - - // if text := h.data.Message.Text; text == "" { - // h.hideMessageBanner() - // } else { - // h.messageBanner.SetText(text) - // h.showMessageBanner() - // } + 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, tview.NewTableCell("my-company").SetStyle(styles.MetricValueStyle)) table.SetCell(1, 0, tview.NewTableCell("Environment: ").SetStyle(styles.MetricNameStyle)) @@ -121,25 +107,24 @@ func (h *Header) getEnvironmentInformationTable() tview.Primitive { table.SetCell(3, 0, tview.NewTableCell("Version: ").SetStyle(styles.MetricNameStyle)) table.SetCell(3, 1, tview.NewTableCell("v0.15.5").SetStyle(styles.MetricValueStyle)) table.SetBorderPadding(1, 1, 2, 1) + return table } func (h *Header) getMetricsTable() tview.Primitive { - h.uptimeTextView = tview.NewTableCell("0s").SetStyle(styles.MetricValueStyle) - h.testRunsTextView = tview.NewTableCell("15").SetStyle(styles.MetricValueStyle) - h.tracesTextView = tview.NewTableCell("15").SetStyle(styles.MetricValueStyle) - h.spansTextView = tview.NewTableCell("61").SetStyle(styles.MetricValueStyle) 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.uptimeTextView) - table.SetCell(1, 0, tview.NewTableCell("Test runs: ").SetStyle(styles.MetricNameStyle)) - table.SetCell(1, 1, h.testRunsTextView) + 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.tracesTextView) + table.SetCell(2, 1, h.tracesTableCell) table.SetCell(3, 0, tview.NewTableCell("Spans: ").SetStyle(styles.MetricNameStyle)) - table.SetCell(3, 1, h.spansTextView) - table.SetBorderPadding(1, 1, 1, 1) + table.SetCell(3, 1, h.spansTableCell) + table.SetBorderPadding(1, 1, 2, 1) + return table } @@ -151,24 +136,6 @@ func (h *Header) hideMessageBanner() { h.Flex.ResizeItem(h.messageBanner, 0, 0) } -const logo = ` _______ _ _ -|__ __| | | | | - | |_ __ __ _ ___ ___| |_ ___ ___| |_ - | | '__/ _\ |/ __/ _ | __/ _ / __| __| - | | | | (_| | (_| __| || __\__ | |_ - |_|_| \__,_|\___\___|\__\___|___/\__| - - ` - -func (h *Header) getTracetestLogo() tview.Primitive { - textView := tview.NewTextView().SetTextColor(styles.HeaderLogoColor) - textView.SetBackgroundColor(styles.HeaderBackgroundColor) - textView.SetText(logo) - textView.SetWrap(false).SetWordWrap(false) - - return textView -} - func (h *Header) setupSensors() { h.sensor.On(events.UptimeChanged, func(e sensors.Event) { var uptime time.Duration 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..ce72f8a3f4 --- /dev/null +++ b/agent/ui/dashboard/components/test_run_list.go @@ -0,0 +1,69 @@ +package components + +import ( + "fmt" + "strings" + + "github.com/kubeshop/tracetest/agent/ui/dashboard/models" + "github.com/kubeshop/tracetest/agent/ui/dashboard/styles" + "github.com/rivo/tview" +) + +var headers = []string{ + "Name", + "Type", + "Endpoint", + "Status", + "When", +} + +type TestRunList struct { + *tview.Table + + testRuns []models.TestRun + + renderScheduler RenderScheduler +} + +func NewTestRunList(renderScheduler RenderScheduler) *TestRunList { + list := &TestRunList{ + Table: tview.NewTable(), + renderScheduler: renderScheduler, + } + + 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) { + fmt.Println(row, column) + }) + + return list +} + +func (l *TestRunList) SetTestRuns(runs []models.TestRun) { + l.testRuns = runs + l.renderScheduler.Render(func() { + 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 index 4538ab2a3e..8c0966fff6 100644 --- a/agent/ui/dashboard/dashboard.go +++ b/agent/ui/dashboard/dashboard.go @@ -7,8 +7,10 @@ import ( "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" ) @@ -16,17 +18,23 @@ type Dashboard struct{} func startUptimeCounter(sensor sensors.Sensor) { ticker := time.NewTicker(time.Second) + fastTicker := time.NewTicker(50 * time.Millisecond) start := time.Now() go func() { for { - <-ticker.C - sensor.Emit(events.UptimeChanged, time.Since(start).Round(time.Second)) + select { + case <-ticker.C: + sensor.Emit(events.UptimeChanged, time.Since(start).Round(time.Second)) + case <-fastTicker.C: + sensor.Emit(events.NewTestRun, models.TestRun{TestID: "1", RunID: "1", Name: "my test", Type: "HTTP", Endpoint: "http://localhost:11633/api/tests", Status: "Awaiting Traces", When: time.Since(start).Round(time.Second)}) + } } }() } func StartDashboard(ctx context.Context) error { app := tview.NewApplication() + tview.Styles.PrimitiveBackgroundColor = styles.HeaderBackgroundColor renderScheduler := components.NewRenderScheduler(app) sensor := sensors.NewSensor() diff --git a/agent/ui/dashboard/events/events.go b/agent/ui/dashboard/events/events.go index e2da936239..53ae7255e1 100644 --- a/agent/ui/dashboard/events/events.go +++ b/agent/ui/dashboard/events/events.go @@ -1,5 +1,7 @@ package events var ( - UptimeChanged = "uptime_changed" + UptimeChanged = "uptime_changed" + NewTestRun = "new_test_run" + UpdatedTestRun = "updated_test_run" ) diff --git a/agent/ui/dashboard/main/main.go b/agent/ui/dashboard/main/main.go index 56f488b56c..a0565b2c6c 100644 --- a/agent/ui/dashboard/main/main.go +++ b/agent/ui/dashboard/main/main.go @@ -2,10 +2,14 @@ package main import ( "context" + "fmt" "github.com/kubeshop/tracetest/agent/ui/dashboard" ) func main() { - dashboard.StartDashboard(context.Background()) + err := dashboard.StartDashboard(context.Background()) + if err != nil { + fmt.Println(err.Error()) + } } diff --git a/agent/ui/dashboard/models/test_runs.go b/agent/ui/dashboard/models/test_runs.go new file mode 100644 index 0000000000..4ee6a27acd --- /dev/null +++ b/agent/ui/dashboard/models/test_runs.go @@ -0,0 +1,13 @@ +package models + +import "time" + +type TestRun struct { + TestID string + RunID string + Name string + Type string + Endpoint string + Status string + When time.Duration +} diff --git a/agent/ui/dashboard/pages/test_runs_page.go b/agent/ui/dashboard/pages/test_runs_page.go index b95745eb5c..c725f0a36a 100644 --- a/agent/ui/dashboard/pages/test_runs_page.go +++ b/agent/ui/dashboard/pages/test_runs_page.go @@ -1,28 +1,86 @@ package pages import ( + "fmt" + "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) + p.Grid. - SetRows(10, 90). + // 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). - AddItem(components.NewHeader(renderScheduler, sensor), 0, 0, 1, 3, 0, 0, true). - AddItem(tview.NewBox(), 1, 0, 1, 3, 0, 0, true) + + // 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) + }) return p } + +func (p *TestRunPage) Focus(delegate func(p tview.Primitive)) { + delegate(p.testRunList) +} diff --git a/agent/ui/dashboard/sensors/sensor.go b/agent/ui/dashboard/sensors/sensor.go index ca1942e309..4a9771192d 100644 --- a/agent/ui/dashboard/sensors/sensor.go +++ b/agent/ui/dashboard/sensors/sensor.go @@ -41,7 +41,6 @@ func (r *sensor) On(eventName string, cb func(Event)) { slice = existingSlice } else { slice = make([]func(Event), 0) - slice = append(slice, cb) } r.listeners[eventName] = append(slice, cb) } diff --git a/agent/ui/dashboard/styles/styles.go b/agent/ui/dashboard/styles/styles.go index 320a14ef5b..f335a27af5 100644 --- a/agent/ui/dashboard/styles/styles.go +++ b/agent/ui/dashboard/styles/styles.go @@ -11,11 +11,20 @@ var ( 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(tcell.NewRGBColor(253, 166, 34)). + 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) ) From cfde26b2858d54dcef2f698a0fd8c7ba975ff444 Mon Sep 17 00:00:00 2001 From: Matheus Nogueira Date: Tue, 13 Feb 2024 16:52:08 -0300 Subject: [PATCH 3/3] feat: agent dashboard start (#3631) * start dashboard if agent mode is set as dashboard * update environment and collector statistics * feat: update list of tests based on test runs received by the agent * update test run counter * make dashboard as part of the desktop option list * open test run in browser * fix panic * make it possible to exit and go back to dashboard * remove dashboard mode from CLI * Chore performance improvements (#3616) * chore(frontend): indentifying performance improvements * chore(frontend): identify performance improvements * feat(frontend): hide dag view for big traces (#3606) * Chore performance improvements tech debt 1 (#3607) * adding async functionality * chore(frontend): DAG improvements * chore(frontend): DAG improvements * chore(frontend): fixing spinner styles * chore(frontend): fixing spinner styles * feat(frontend): fixing tests * chore(frontend): fixing spinner styles * chore(frontend): FE Improvements for the Test View (#3613) * adding async functionality * chore(frontend): DAG improvements * chore(frontend): DAG improvements * chore(frontend): fixing spinner styles * chore(frontend): fixing spinner styles * feat(frontend): fixing tests * chore(frontend): fixing spinner styles * chore(frontend): FE Improvements for the Test View * chore(frontend): reverting editor changes * chore(frontend): Adding memoization for useComplete hook * chore(frontend): Adding Search Service usage * chore(frontend): cleanup * feat(frontend): implement virtual list for timeline view (#3617) * feat(frontend): implement virtual list for timeline view * remove prop * add header and collapse * feat: add timeline view connectors (#3623) * feat(frontend): Implemeting new Timeline for the Test Page (#3627) * feat(frontend): Implemeting new Timeline for the Test Page * feat(frontend): Fixing tests * feat(frontend): Fixing tests * Chore performance improvements span search (#3629) * feat(frontend): Implemeting new Timeline for the Test Page * feat(frontend): Implementing span search for analyzer and test views * feat(frontend): read improvements * feat(frontend): adding single line input component * feat(frontend): updating analyzer styles * feat(frontend): Fixing tests * feat(frontend): Fixing tests --------- Co-authored-by: Jorge Padilla * disable logger in dashboard mode * keep old log level * add comment about why we are disabling logs in dashboard mode --------- Co-authored-by: Oscar Reyes Co-authored-by: Jorge Padilla --- agent/collector/collector.go | 23 ++- agent/collector/ingester.go | 17 ++ agent/config/config.go | 6 + agent/runner/runner.go | 56 +++++++ agent/runner/runstrategy_dashboard.go | 148 +++++++++++++++++ agent/runner/runstrategy_desktop.go | 68 ++++---- agent/ui/dashboard/components/header.go | 68 ++++++-- .../ui/dashboard/components/test_run_list.go | 33 +++- agent/ui/dashboard/dashboard.go | 18 ++- agent/ui/dashboard/events/events.go | 9 +- agent/ui/dashboard/main/main.go | 9 +- agent/ui/dashboard/models/environment.go | 8 + agent/ui/dashboard/models/test_runs.go | 1 + agent/ui/dashboard/pages/test_runs_page.go | 24 ++- agent/ui/dashboard/sensors/sensor.go | 16 +- agent/workers/trigger.go | 8 + .../e2e/TestRunDetail/CreateAssertion.spec.ts | 8 - web/cypress/support/commands.ts | 1 - web/docker-compose.yaml | 127 +++++++++++++++ web/package-lock.json | 42 +++++ web/package.json | 5 + .../AnalyzerResult/AnalyzerResult.styled.ts | 16 +- .../AnalyzerResult/AnalyzerResult.tsx | 10 +- web/src/components/AnalyzerResult/Plugins.tsx | 11 +- web/src/components/AnalyzerResult/Rule.tsx | 125 +++++---------- .../components/AnalyzerResult/RuleResult.tsx | 64 ++++++++ .../components/Fields/Auth/AuthApiKeyBase.tsx | 7 +- web/src/components/Fields/Auth/AuthBasic.tsx | 8 +- web/src/components/Fields/Auth/AuthBearer.tsx | 5 +- web/src/components/Fields/Headers/Headers.tsx | 7 +- .../Fields/KeyValueList/KeyValueList.tsx | 7 +- .../components/Fields/Metadata/Metadata.tsx | 7 +- .../components/Fields/MultiURL/MultiURL.tsx | 5 +- .../components/Fields/PlainAuth/Fields.tsx | 7 +- web/src/components/Fields/URL/URL.tsx | 6 +- .../Fields/VariableName/VariableName.tsx | 5 +- .../Expression/hooks/useAutoComplete.ts | 16 +- .../Inputs/Editor/Selector/Selector.tsx | 11 +- .../Editor/Selector/hooks/useAutoComplete.ts | 16 +- .../Inputs/SingleLine/SingleLine.tsx | 12 ++ web/src/components/Inputs/SingleLine/index.ts | 2 + .../LoadingSpinner/LoadingSpinner.styled.ts | 8 + web/src/components/LoadingSpinner/index.ts | 4 + .../components/RunDetailLayout/HeaderLeft.tsx | 21 +-- web/src/components/RunDetailTest/TestDAG.tsx | 62 ++++++++ .../components/RunDetailTest/TestPanel.tsx | 14 +- .../RunDetailTest/Visualization.tsx | 72 ++------- .../RunDetailTrace/AnalyzerPanel.tsx | 7 +- .../RunDetailTrace/RunDetailTrace.tsx | 5 + web/src/components/RunDetailTrace/Search.tsx | 27 +--- .../components/RunDetailTrace/TraceDAG.tsx | 61 +++++++ .../components/RunDetailTrace/TracePanel.tsx | 12 +- .../RunDetailTrace/Visualization.tsx | 68 +++----- .../TestPlugins/Forms/Kafka/Kafka.tsx | 5 +- .../components/TestResults/TestResults.tsx | 2 +- .../components/TestSpec/TestSpec.styled.ts | 1 + web/src/components/TestSpecDetail/Content.tsx | 85 +++++----- .../components/TestSpecDetail/ResultCard.tsx | 65 ++++++++ web/src/components/TestSpecDetail/Search.tsx | 70 ++++++++ .../components/TestSpecDetail/SpanHeader.tsx | 30 ++-- .../TestSpecDetail/TestSpecDetail.styled.ts | 25 ++- .../TestSpecDetail/TestSpecDetail.tsx | 59 +++---- .../components/TestSpecs/TestSpecs.styled.ts | 6 - web/src/components/TestSpecs/TestSpecs.tsx | 41 +++-- .../Visualization/components/DAG/DAG.tsx | 5 +- .../components/Navigation/Navigation.tsx | 1 + .../components/Switch/Switch.styled.ts | 7 +- .../components/Switch/Switch.tsx | 21 ++- .../Timeline/BaseSpanNode/BaseSpanNodeV2.tsx | 62 ++++++++ .../Timeline/BaseSpanNode/ConnectorV2.tsx | 56 +++++++ .../components/Timeline/Header.tsx | 22 +++ .../components/Timeline/ListWrapper.tsx | 33 ++++ .../components/Timeline/NavigationWrapper.tsx | 10 ++ .../components/Timeline/SpanNodeFactoryV2.tsx | 30 ++++ .../components/Timeline/Ticks/Ticks.styled.ts | 37 +++++ .../components/Timeline/Ticks/Ticks.tsx | 40 +++++ .../components/Timeline/Timeline.provider.tsx | 127 +++++++++++++++ .../components/Timeline/TimelineV2.styled.ts | 150 ++++++++++++++++++ .../components/Timeline/TimelineV2.tsx | 37 +++++ .../TraceSpanNode/TraceSpanNodeV2.tsx | 19 +++ web/src/constants/Timeline.constants.ts | 1 + web/src/constants/Visualization.constants.ts | 2 + web/src/hooks/useSpanData.ts | 2 + web/src/models/DAG.model.ts | 3 + web/src/models/SearchSpansResult.model.ts | 22 +++ web/src/models/Span.model.ts | 11 +- web/src/models/TestRun.model.ts | 4 +- web/src/models/Trace.model.ts | 25 ++- .../models/__tests__/TestRun.model.test.ts | 2 - .../providers/TestRun/TestRun.provider.tsx | 5 +- .../TestSpecs/TestSpecs.provider.tsx | 10 +- web/src/redux/actions/Router.actions.ts | 9 +- web/src/redux/actions/TestSpecs.actions.ts | 1 + .../Tracetest/endpoints/TestRun.endpoint.ts | 9 ++ web/src/redux/apis/Tracetest/index.ts | 2 + web/src/redux/slices/DAG.slice.ts | 15 +- web/src/redux/slices/Trace.slice.ts | 16 +- web/src/selectors/Assertion.selectors.ts | 2 +- web/src/selectors/Editor.selectors.ts | 26 +++ web/src/selectors/Span.selectors.ts | 4 +- web/src/selectors/TestRun.selectors.ts | 2 +- web/src/services/Analyzer.service.ts | 13 +- web/src/services/Assertion.service.ts | 3 +- web/src/services/DAG.service.ts | 17 +- web/src/services/Span.service.ts | 1 + web/src/services/TestRun.service.ts | 12 +- web/src/services/Timeline.service.ts | 18 +++ web/src/utils/Common.ts | 9 ++ web/src/utils/Date.ts | 44 +++++ 109 files changed, 2168 insertions(+), 571 deletions(-) create mode 100644 agent/runner/runstrategy_dashboard.go create mode 100644 agent/ui/dashboard/models/environment.go create mode 100644 web/docker-compose.yaml create mode 100644 web/src/components/AnalyzerResult/RuleResult.tsx create mode 100644 web/src/components/Inputs/SingleLine/SingleLine.tsx create mode 100644 web/src/components/Inputs/SingleLine/index.ts create mode 100644 web/src/components/LoadingSpinner/LoadingSpinner.styled.ts create mode 100644 web/src/components/RunDetailTest/TestDAG.tsx create mode 100644 web/src/components/RunDetailTrace/TraceDAG.tsx create mode 100644 web/src/components/TestSpecDetail/ResultCard.tsx create mode 100644 web/src/components/TestSpecDetail/Search.tsx create mode 100644 web/src/components/Visualization/components/Timeline/BaseSpanNode/BaseSpanNodeV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/BaseSpanNode/ConnectorV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/Header.tsx create mode 100644 web/src/components/Visualization/components/Timeline/ListWrapper.tsx create mode 100644 web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx create mode 100644 web/src/components/Visualization/components/Timeline/SpanNodeFactoryV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts create mode 100644 web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx create mode 100644 web/src/components/Visualization/components/Timeline/Timeline.provider.tsx create mode 100644 web/src/components/Visualization/components/Timeline/TimelineV2.styled.ts create mode 100644 web/src/components/Visualization/components/Timeline/TimelineV2.tsx create mode 100644 web/src/components/Visualization/components/Timeline/TraceSpanNode/TraceSpanNodeV2.tsx create mode 100644 web/src/models/SearchSpansResult.model.ts create mode 100644 web/src/selectors/Editor.selectors.ts 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 index 476aa43dc9..85dadd3c3b 100644 --- a/agent/ui/dashboard/components/header.go +++ b/agent/ui/dashboard/components/header.go @@ -5,6 +5,7 @@ import ( "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" @@ -41,7 +42,12 @@ type Header struct { sensor sensors.Sensor data HeaderData - messageBanner *MessageBanner + messageBanner *MessageBanner + + organizationTableCell *tview.TableCell + environmentTableCell *tview.TableCell + agentVersionTableCell *tview.TableCell + uptimeTableCell *tview.TableCell runsTableCell *tview.TableCell tracesTableCell *tview.TableCell @@ -50,14 +56,17 @@ type Header struct { func NewHeader(renderScheduler RenderScheduler, sensor sensors.Sensor) *Header { h := &Header{ - Flex: tview.NewFlex(), - renderScheduler: renderScheduler, - sensor: sensor, - messageBanner: NewMessageBanner(renderScheduler), - 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), + 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() @@ -99,13 +108,13 @@ func (h *Header) getEnvironmentInformationTable() tview.Primitive { 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, tview.NewTableCell("my-company").SetStyle(styles.MetricValueStyle)) + table.SetCell(0, 1, h.organizationTableCell) table.SetCell(1, 0, tview.NewTableCell("Environment: ").SetStyle(styles.MetricNameStyle)) - table.SetCell(1, 1, tview.NewTableCell("steve-dev").SetStyle(styles.MetricValueStyle)) + table.SetCell(1, 1, h.environmentTableCell) table.SetCell(2, 0, tview.NewTableCell("Last Tracing Backend: ").SetStyle(styles.MetricNameStyle)) - table.SetCell(2, 1, tview.NewTableCell("Jaeger").SetStyle(styles.MetricValueStyle)) + table.SetCell(2, 1, tview.NewTableCell("").SetStyle(styles.MetricValueStyle)) table.SetCell(3, 0, tview.NewTableCell("Version: ").SetStyle(styles.MetricNameStyle)) - table.SetCell(3, 1, tview.NewTableCell("v0.15.5").SetStyle(styles.MetricValueStyle)) + table.SetCell(3, 1, h.agentVersionTableCell) table.SetBorderPadding(1, 1, 2, 1) return table @@ -137,11 +146,42 @@ func (h *Header) hideMessageBanner() { } func (h *Header) setupSensors() { - h.sensor.On(events.UptimeChanged, func(e sensors.Event) { + 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/test_run_list.go b/agent/ui/dashboard/components/test_run_list.go index ce72f8a3f4..35bf7c9e50 100644 --- a/agent/ui/dashboard/components/test_run_list.go +++ b/agent/ui/dashboard/components/test_run_list.go @@ -1,10 +1,12 @@ package components import ( - "fmt" "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" ) @@ -14,21 +16,22 @@ var headers = []string{ "Type", "Endpoint", "Status", - "When", + "Age", } type TestRunList struct { *tview.Table testRuns []models.TestRun - + sensor sensors.Sensor renderScheduler RenderScheduler } -func NewTestRunList(renderScheduler RenderScheduler) *TestRunList { +func NewTestRunList(renderScheduler RenderScheduler, sensor sensors.Sensor) *TestRunList { list := &TestRunList{ Table: tview.NewTable(), renderScheduler: renderScheduler, + sensor: sensor, } for i, header := range headers { @@ -45,9 +48,18 @@ func NewTestRunList(renderScheduler RenderScheduler) *TestRunList { list.SetSelectable(true, false) list.Select(0, 0) list.SetSelectedFunc(func(row, column int) { - fmt.Println(row, column) + // 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 } @@ -58,6 +70,17 @@ func (l *TestRunList) SetTestRuns(runs []models.TestRun) { }) } +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)) diff --git a/agent/ui/dashboard/dashboard.go b/agent/ui/dashboard/dashboard.go index 8c0966fff6..1701a2bac2 100644 --- a/agent/ui/dashboard/dashboard.go +++ b/agent/ui/dashboard/dashboard.go @@ -5,6 +5,7 @@ import ( "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" @@ -18,31 +19,36 @@ type Dashboard struct{} func startUptimeCounter(sensor sensors.Sensor) { ticker := time.NewTicker(time.Second) - fastTicker := time.NewTicker(50 * time.Millisecond) start := time.Now() go func() { for { select { case <-ticker.C: - sensor.Emit(events.UptimeChanged, time.Since(start).Round(time.Second)) - case <-fastTicker.C: - sensor.Emit(events.NewTestRun, models.TestRun{TestID: "1", RunID: "1", Name: "my test", Type: "HTTP", Endpoint: "http://localhost:11633/api/tests", Status: "Awaiting Traces", When: time.Since(start).Round(time.Second)}) + sensor.Emit(events.TimeChanged, time.Since(start).Round(time.Second)) } } }() } -func StartDashboard(ctx context.Context) error { +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 := sensors.NewSensor() + 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) } diff --git a/agent/ui/dashboard/events/events.go b/agent/ui/dashboard/events/events.go index 53ae7255e1..a4e8942d16 100644 --- a/agent/ui/dashboard/events/events.go +++ b/agent/ui/dashboard/events/events.go @@ -1,7 +1,14 @@ package events var ( - UptimeChanged = "uptime_changed" + 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 index a0565b2c6c..aa72ce105d 100644 --- a/agent/ui/dashboard/main/main.go +++ b/agent/ui/dashboard/main/main.go @@ -5,10 +5,17 @@ import ( "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()) + 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 index 4ee6a27acd..9664b39f45 100644 --- a/agent/ui/dashboard/models/test_runs.go +++ b/agent/ui/dashboard/models/test_runs.go @@ -9,5 +9,6 @@ type TestRun struct { 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 index c725f0a36a..10d019a567 100644 --- a/agent/ui/dashboard/pages/test_runs_page.go +++ b/agent/ui/dashboard/pages/test_runs_page.go @@ -3,6 +3,7 @@ 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" @@ -30,7 +31,7 @@ func NewTestRunPage(renderScheduler components.RenderScheduler, sensor sensors.S } p.header = components.NewHeader(renderScheduler, sensor) - p.testRunList = components.NewTestRunList(renderScheduler) + 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 @@ -78,6 +79,27 @@ func NewTestRunPage(renderScheduler components.RenderScheduler, sensor sensors.S 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 } diff --git a/agent/ui/dashboard/sensors/sensor.go b/agent/ui/dashboard/sensors/sensor.go index 4a9771192d..3f46a31c1e 100644 --- a/agent/ui/dashboard/sensors/sensor.go +++ b/agent/ui/dashboard/sensors/sensor.go @@ -9,6 +9,7 @@ import ( type Sensor interface { On(string, func(Event)) Emit(string, interface{}) + Reset() } type Event struct { @@ -27,14 +28,21 @@ func (e *Event) Unmarshal(target interface{}) error { 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 { @@ -43,6 +51,10 @@ func (r *sensor) On(eventName string, cb func(Event)) { 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{}) { @@ -51,7 +63,9 @@ func (r *sensor) Emit(eventName string, event interface{}) { Name: eventName, data: event, } - + + r.lastEvent[eventName] = e + for _, listener := range listeners { listener(e) } 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/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts b/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts index d4ac6ae833..404641d870 100644 --- a/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts +++ b/web/cypress/e2e/TestRunDetail/CreateAssertion.spec.ts @@ -48,7 +48,6 @@ describe('Create Assertion', () => { cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -74,7 +73,6 @@ describe('Create Assertion', () => { cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -98,8 +96,6 @@ describe('Create Assertion', () => { cy.selectOperator(1); cy.get('[data-cy=assertion-form-submit-button]').click(); - - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -124,8 +120,6 @@ describe('Create Assertion', () => { cy.selectOperator(1); cy.get('[data-cy=assertion-form-submit-button]').click(); - - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); @@ -151,9 +145,7 @@ describe('Create Assertion', () => { cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('exist'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 2); - cy.get('[data-cy=trace-actions-revert-all').click(); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 0); }); diff --git a/web/cypress/support/commands.ts b/web/cypress/support/commands.ts index 62a6d24621..8e14833ba4 100644 --- a/web/cypress/support/commands.ts +++ b/web/cypress/support/commands.ts @@ -224,7 +224,6 @@ Cypress.Commands.add('createAssertion', () => { cy.get('[data-cy=assertion-check-operator]').click({force: true}); cy.get('[data-cy=assertion-form-submit-button]').click(); - cy.get('[data-cy=test-specs-container]').should('be.visible'); cy.get('[data-cy=test-spec-container]').should('have.lengthOf', 1); }); diff --git a/web/docker-compose.yaml b/web/docker-compose.yaml new file mode 100644 index 0000000000..e9cfa7ef85 --- /dev/null +++ b/web/docker-compose.yaml @@ -0,0 +1,127 @@ +version: '3.2' +services: + tracetest: + restart: unless-stopped + image: kubeshop/tracetest:${TAG:-latest} + extra_hosts: + - 'host.docker.internal:host-gateway' + build: + context: . + volumes: + - type: bind + source: ../local-config/tracetest.config.yaml + target: /app/tracetest.yaml + - type: bind + source: ../local-config/tracetest.provision.yaml + target: /app/provisioning.yaml + ports: + - 11633:11633 + command: --provisioning-file /app/provisioning.yaml + healthcheck: + test: ['CMD', 'wget', '--spider', 'localhost:11633'] + interval: 1s + timeout: 3s + retries: 60 + depends_on: + postgres: + condition: service_healthy + environment: + TRACETEST_DEV: ${TRACETEST_DEV} + TRACETEST_TESTPIPELINES_TRIGGEREXECUTE_ENABLED: ${TRACETEST_TESTPIPELINES_TRIGGEREXECUTE_ENABLED} + TRACETEST_TESTPIPELINES_TRACEFETCH_ENABLED: ${TRACETEST_TESTPIPELINES_TRACEFETCH_ENABLED} + TRACETEST_DATASTOREPIPELINES_TESTCONNECTION_ENABLED: ${TRACETEST_DATASTOREPIPELINES_TESTCONNECTION_ENABLED} + + postgres: + image: postgres:15.2 + environment: + POSTGRES_PASSWORD: postgres + POSTGRES_USER: postgres + ports: + - 5432:5432 + healthcheck: + test: pg_isready -U "$$POSTGRES_USER" -d "$$POSTGRES_DB" + interval: 1s + timeout: 5s + retries: 60 + + otel-collector: + image: otel/opentelemetry-collector-contrib:0.59.0 + extra_hosts: + - 'host.docker.internal:host-gateway' + ports: + - '4317:4317' + command: + - '--config' + - '/otel-local-config.yaml' + volumes: + - ../local-config/collector.config.yaml:/otel-local-config.yaml + depends_on: + - tracetest + + cache: + image: redis:6 + ports: + - 6379:6379 + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 1s + timeout: 3s + retries: 60 + + queue: + image: rabbitmq:3.12 + restart: unless-stopped + ports: + - 5672:5672 + - 15672:15672 + healthcheck: + test: rabbitmq-diagnostics -q check_running + interval: 1s + timeout: 5s + retries: 60 + + demo-api: + image: kubeshop/demo-pokemon-api:latest + restart: unless-stopped + pull_policy: always + environment: + REDIS_URL: cache + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + RABBITMQ_HOST: queue + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + COLLECTOR_ENDPOINT: http://otel-collector:4317 + NPM_RUN_COMMAND: api + healthcheck: + test: ['CMD', 'wget', '--spider', 'localhost:8081'] + interval: 1s + timeout: 3s + retries: 60 + ports: + - 8081:8081 + depends_on: + postgres: + condition: service_healthy + cache: + condition: service_healthy + queue: + condition: service_healthy + + worker: + image: kubeshop/demo-pokemon-api:latest + restart: unless-stopped + pull_policy: always + environment: + REDIS_URL: cache + DATABASE_URL: postgresql://postgres:postgres@postgres:5432/postgres?schema=public + RABBITMQ_HOST: queue + POKE_API_BASE_URL: https://pokeapi.co/api/v2 + COLLECTOR_ENDPOINT: http://otel-collector:4317 + NPM_RUN_COMMAND: worker + depends_on: + postgres: + condition: service_healthy + cache: + condition: service_healthy + queue: + condition: service_healthy + diff --git a/web/package-lock.json b/web/package-lock.json index 2bcb1b0ac0..d356e453f8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -60,6 +60,8 @@ "react-scripts": "5.0.1", "react-spaces": "0.3.8", "react-syntax-highlighter": "15.5.0", + "react-virtualized-auto-sizer": "1.0.22", + "react-window": "1.8.10", "redux-first-history": "5.0.12", "styled-components": "5.3.3", "typescript": "5.0.2" @@ -77,6 +79,7 @@ "@types/lodash": "4.14.181", "@types/postman-collection": "3.5.7", "@types/react-syntax-highlighter": "15.5.7", + "@types/react-window": "1.8.8", "@types/styled-components": "5.1.21", "concurrently": "7.2.1", "cypress": "13.2.0", @@ -7535,6 +7538,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.17.1", "license": "MIT", @@ -21170,6 +21182,36 @@ "react": ">= 0.14.0" } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.22", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.22.tgz", + "integrity": "sha512-2CGT/4rZ6jvVkKqzJGnZlyQxj4rWPKAwZR80vMlmpYToN18xaB0yIODOoBltWZLbSgpHBpIk0Ae1nrVO9hVClA==", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-window": { + "version": "1.8.10", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.10.tgz", + "integrity": "sha512-Y0Cx+dnU6NLa5/EvoHukUD0BklJ8qITCtVEPY1C/nL8wwoZ0b5aEw8Ff1dOVHw7fCzMt55XfJDd8S8W8LCaUCg==", + "dependencies": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + }, + "engines": { + "node": ">8.0.0" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-window/node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/readable-stream": { "version": "3.6.0", "license": "MIT", diff --git a/web/package.json b/web/package.json index b641c4f128..7050ec53a1 100644 --- a/web/package.json +++ b/web/package.json @@ -56,6 +56,8 @@ "react-scripts": "5.0.1", "react-spaces": "0.3.8", "react-syntax-highlighter": "15.5.0", + "react-virtualized-auto-sizer": "1.0.22", + "react-window": "1.8.10", "redux-first-history": "5.0.12", "styled-components": "5.3.3", "typescript": "5.0.2" @@ -81,6 +83,8 @@ "cy:open": "cypress open", "cy:run": "cypress run", "cy:ci": "cypress run --parallel --record --key $CYPRESS_RECORD_KEY", + "cy:local:run": "POKEMON_HTTP_ENDPOINT=http://demo-api:8081 cypress run", + "cy:local:open": "POKEMON_HTTP_ENDPOINT=http://demo-api:8081 cypress open", "prettier": "prettier --write ./src", "less": "lessc --js src/antd-theme/antd-customized.less src/antd-theme/antd-customized.css" }, @@ -122,6 +126,7 @@ "@types/lodash": "4.14.181", "@types/postman-collection": "3.5.7", "@types/react-syntax-highlighter": "15.5.7", + "@types/react-window": "1.8.8", "@types/styled-components": "5.1.21", "concurrently": "7.2.1", "cypress": "13.2.0", diff --git a/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts b/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts index a04f33a243..cf3d442393 100644 --- a/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts +++ b/web/src/components/AnalyzerResult/AnalyzerResult.styled.ts @@ -47,27 +47,19 @@ export const GlobalScoreContainer = styled.div` justify-content: center; `; -export const RuleContainer = styled.div` - border-bottom: ${({theme}) => `1px dashed ${theme.color.borderLight}`}; - padding-bottom: 16px; - margin-bottom: 16px; - margin-left: 43px; -`; - export const RuleHeader = styled.div` display: flex; flex-direction: row; justify-content: space-between; `; -export const Column = styled.div` - display: flex; - flex-direction: column; - margin-bottom: 8px; +export const Column = styled(RuleHeader)` + width: 95%; `; -export const RuleBody = styled(Column)` +export const RuleBody = styled(Column)<{$resultCount: number}>` padding-left: 20px; + height: ${({$resultCount}) => ($resultCount > 10 ? '100vh' : `${$resultCount * 32}px`)}; `; export const Subtitle = styled(Typography.Title)` diff --git a/web/src/components/AnalyzerResult/AnalyzerResult.tsx b/web/src/components/AnalyzerResult/AnalyzerResult.tsx index 08e66870de..45f21a36ed 100644 --- a/web/src/components/AnalyzerResult/AnalyzerResult.tsx +++ b/web/src/components/AnalyzerResult/AnalyzerResult.tsx @@ -2,7 +2,6 @@ import BetaBadge from 'components/BetaBadge/BetaBadge'; import Link from 'components/Link'; import {COMMUNITY_SLACK_URL, OCTOLIINT_ISSUE_URL} from 'constants/Common.constants'; import LinterResult from 'models/LinterResult.model'; -import Trace from 'models/Trace.model'; import {useSettingsValues} from 'providers/SettingsValues/SettingsValues.provider'; import * as S from './AnalyzerResult.styled'; import Empty from './Empty'; @@ -11,10 +10,9 @@ import Plugins from './Plugins'; interface IProps { result: LinterResult; - trace: Trace; } -const AnalyzerResult = ({result: {score, minimumScore, plugins = [], passed}, trace}: IProps) => { +const AnalyzerResult = ({result: {score, minimumScore, plugins = [], passed}}: IProps) => { const {linter} = useSettingsValues(); return ( @@ -31,13 +29,13 @@ const AnalyzerResult = ({result: {score, minimumScore, plugins = [], passed}, tr It can be globally disabled for all tests in the settings page.{' '} )} - We value your feedback on this beta release. Share your thoughts on Slack or add - them to this Issue. + We value your feedback on this beta release. Share your thoughts on Slack or + add them to this Issue. {plugins.length ? ( <> - + ) : ( diff --git a/web/src/components/AnalyzerResult/Plugins.tsx b/web/src/components/AnalyzerResult/Plugins.tsx index 4f6a347d21..a88d9e30e0 100644 --- a/web/src/components/AnalyzerResult/Plugins.tsx +++ b/web/src/components/AnalyzerResult/Plugins.tsx @@ -1,7 +1,8 @@ import {Space, Switch, Typography} from 'antd'; import {useState} from 'react'; import {LinterResultPlugin} from 'models/LinterResult.model'; -import Trace from 'models/Trace.model'; +import {useAppSelector} from 'redux/hooks'; +import TraceSelectors from 'selectors/Trace.selectors'; import TraceAnalyzerAnalytics from 'services/Analytics/TraceAnalyzer.service'; import AnalyzerService from 'services/Analyzer.service'; import * as S from './AnalyzerResult.styled'; @@ -11,12 +12,12 @@ import Collapse, {CollapsePanel} from '../Collapse'; interface IProps { plugins: LinterResultPlugin[]; - trace: Trace; } -const Plugins = ({plugins: rawPlugins, trace}: IProps) => { +const Plugins = ({plugins: rawPlugins}: IProps) => { const [onlyErrors, setOnlyErrors] = useState(false); - const plugins = AnalyzerService.getPlugins(rawPlugins, onlyErrors); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); + const plugins = AnalyzerService.getPlugins(rawPlugins, onlyErrors, matchedSpans); return ( <> @@ -38,7 +39,7 @@ const Plugins = ({plugins: rawPlugins, trace}: IProps) => { key={plugin.name} > {plugin.rules.map(rule => ( - + ))} ))} diff --git a/web/src/components/AnalyzerResult/Rule.tsx b/web/src/components/AnalyzerResult/Rule.tsx index 9480e0f04e..dbf81025ba 100644 --- a/web/src/components/AnalyzerResult/Rule.tsx +++ b/web/src/components/AnalyzerResult/Rule.tsx @@ -1,103 +1,54 @@ -import {useCallback} from 'react'; -import {CaretUpFilled} from '@ant-design/icons'; +import {FixedSizeList as List} from 'react-window'; +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; import {Space, Tooltip, Typography} from 'antd'; +import {PercentageOutlined} from '@ant-design/icons'; import {LinterResultPluginRule} from 'models/LinterResult.model'; -import Trace from 'models/Trace.model'; -import Span from 'models/Span.model'; import {LinterRuleErrorLevel} from 'models/Linter.model'; -import {useAppDispatch} from 'redux/hooks'; -import {selectSpan} from 'redux/slices/Trace.slice'; -import TraceAnalyzerAnalytics from 'services/Analytics/TraceAnalyzer.service'; import * as S from './AnalyzerResult.styled'; import RuleIcon from './RuleIcon'; -import RuleLink from './RuleLink'; +import RuleResult from './RuleResult'; +import Collapse, {CollapsePanel} from '../Collapse'; interface IProps { rule: LinterResultPluginRule; - trace: Trace; } -function getSpanName(spans: Span[], spanId: string) { - const span = spans.find(s => s.id === spanId); - return span?.name ?? ''; -} - -const Rule = ({ - rule: {id, tips, passed, description, name, errorDescription, results = [], level, weight = 0}, - trace, -}: IProps) => { - const dispatch = useAppDispatch(); - - const onSpanResultClick = useCallback( - (spanId: string) => { - TraceAnalyzerAnalytics.onSpanNameClick(); - dispatch(selectSpan({spanId})); - }, - [dispatch] - ); - +const Rule = ({rule: {tips, id, passed, description, name, level, results, weight = 0}, rule}: IProps) => { return ( - - - - - - - {name} - - - - - {description} - - {level === LinterRuleErrorLevel.ERROR && ( - - Weight: {weight} - - )} - - - - {results?.map((result, resultIndex) => ( - // eslint-disable-next-line react/no-array-index-key -
- } - onClick={() => onSpanResultClick(result.spanId)} - type="link" - $error={!result.passed} - > - {getSpanName(trace.spans, result.spanId)} - - - {!result.passed && result.errors.length > 1 && ( - <> -
- {errorDescription} -
- - {result.errors.map(error => ( -
  • - - {error.value} - -
  • - ))} -
    - + + + + + + + {name} + + {description} + + + {level === LinterRuleErrorLevel.ERROR && ( + + {weight} + + )} - - {!result.passed && result.errors.length === 1 && ( -
    - {result.errors[0].description} -
    + + } + key={id} + > + + + {({height, width}: Size) => ( + + {RuleResult} + )} - - {!result.passed && } -
    - ))} -
    -
    + + + + ); }; diff --git a/web/src/components/AnalyzerResult/RuleResult.tsx b/web/src/components/AnalyzerResult/RuleResult.tsx new file mode 100644 index 0000000000..89a42f8bd1 --- /dev/null +++ b/web/src/components/AnalyzerResult/RuleResult.tsx @@ -0,0 +1,64 @@ +import {Tooltip, Typography} from 'antd'; +import {CaretUpFilled} from '@ant-design/icons'; +import {useCallback, useMemo} from 'react'; +import {LinterResultPluginRule} from 'models/LinterResult.model'; +import {useAppDispatch} from 'redux/hooks'; +import {selectSpan} from 'redux/slices/Trace.slice'; +import {useTestRun} from 'providers/TestRun/TestRun.provider'; +import TraceAnalyzerAnalytics from 'services/Analytics/TraceAnalyzer.service'; +import * as S from './AnalyzerResult.styled'; +import RuleLink from './RuleLink'; + +interface IProps { + index: number; + data: LinterResultPluginRule; + style: React.CSSProperties; +} + +const RuleResult = ({index, data: {results, id, errorDescription}, style}: IProps) => { + const {spanId, passed, errors} = useMemo(() => results[index], [results, index]); + const dispatch = useAppDispatch(); + const { + run: {trace}, + } = useTestRun(); + + const onClick = useCallback(() => { + TraceAnalyzerAnalytics.onSpanNameClick(); + dispatch(selectSpan({spanId})); + }, [dispatch, spanId]); + + return ( +
    + } onClick={onClick} type="link" $error={!passed}> + {trace.flat[spanId].name ?? ''} + + + {!passed && errors.length > 1 && ( + <> +
    + {errorDescription} +
    + + {errors.map(error => ( +
  • + + {error.value} + +
  • + ))} +
    + + )} + + {!passed && errors.length === 1 && ( +
    + {errors[0].description} +
    + )} + + {!passed && } +
    + ); +}; + +export default RuleResult; diff --git a/web/src/components/Fields/Auth/AuthApiKeyBase.tsx b/web/src/components/Fields/Auth/AuthApiKeyBase.tsx index feb938f702..46d045fc76 100644 --- a/web/src/components/Fields/Auth/AuthApiKeyBase.tsx +++ b/web/src/components/Fields/Auth/AuthApiKeyBase.tsx @@ -1,7 +1,6 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './Auth.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -17,7 +16,7 @@ const AuthApiKeyBase = ({baseName}: IProps) => ( label="Key" rules={[{required: true}]} > - + ( label="Value" rules={[{required: true}]} > - + diff --git a/web/src/components/Fields/Auth/AuthBasic.tsx b/web/src/components/Fields/Auth/AuthBasic.tsx index d8444a84ef..c98d055873 100644 --- a/web/src/components/Fields/Auth/AuthBasic.tsx +++ b/web/src/components/Fields/Auth/AuthBasic.tsx @@ -1,8 +1,6 @@ import {Form} from 'antd'; -import React from 'react'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './Auth.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -18,7 +16,7 @@ const AuthBasic = ({baseName}: IProps) => ( label="Username" rules={[{required: true}]} > - + ( data-cy="basic-password" rules={[{required: true}]} > - + diff --git a/web/src/components/Fields/Auth/AuthBearer.tsx b/web/src/components/Fields/Auth/AuthBearer.tsx index 7f22363f58..37a6e8f10f 100644 --- a/web/src/components/Fields/Auth/AuthBearer.tsx +++ b/web/src/components/Fields/Auth/AuthBearer.tsx @@ -1,6 +1,5 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -8,7 +7,7 @@ interface IProps { const AuthBearer = ({baseName}: IProps) => ( - + ); diff --git a/web/src/components/Fields/Headers/Headers.tsx b/web/src/components/Fields/Headers/Headers.tsx index de8462e793..e1fa486ff7 100644 --- a/web/src/components/Fields/Headers/Headers.tsx +++ b/web/src/components/Fields/Headers/Headers.tsx @@ -1,9 +1,8 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; import {DEFAULT_HEADERS, IKeyValue} from 'constants/Test.constants'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './Headers.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { initialValue?: IKeyValue[]; @@ -26,11 +25,11 @@ const Headers = ({ {fields.map((field, index) => ( - + - + diff --git a/web/src/components/Fields/KeyValueList/KeyValueList.tsx b/web/src/components/Fields/KeyValueList/KeyValueList.tsx index 4d0efbf6f4..809d719b15 100644 --- a/web/src/components/Fields/KeyValueList/KeyValueList.tsx +++ b/web/src/components/Fields/KeyValueList/KeyValueList.tsx @@ -1,9 +1,8 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import {IKeyValue} from 'constants/Test.constants'; import * as S from './KeyValueList.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { name?: string; @@ -31,13 +30,13 @@ const KeyValueList = ({ - + - + diff --git a/web/src/components/Fields/Metadata/Metadata.tsx b/web/src/components/Fields/Metadata/Metadata.tsx index af006b56d9..44019ca0c2 100644 --- a/web/src/components/Fields/Metadata/Metadata.tsx +++ b/web/src/components/Fields/Metadata/Metadata.tsx @@ -1,8 +1,7 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; -import {SupportedEditors} from 'constants/Editor.constants'; -import {Editor} from 'components/Inputs'; import * as S from './Metadata.styled'; +import SingleLine from '../../Inputs/SingleLine'; const Metadata = () => ( @@ -13,13 +12,13 @@ const Metadata = () => ( - + - + diff --git a/web/src/components/Fields/MultiURL/MultiURL.tsx b/web/src/components/Fields/MultiURL/MultiURL.tsx index 8648829153..436e31b4bd 100644 --- a/web/src/components/Fields/MultiURL/MultiURL.tsx +++ b/web/src/components/Fields/MultiURL/MultiURL.tsx @@ -1,8 +1,7 @@ import {PlusOutlined} from '@ant-design/icons'; import {Button, Form} from 'antd'; -import {SupportedEditors} from 'constants/Editor.constants'; -import {Editor} from 'components/Inputs'; import * as S from './MultiURL.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { name?: string[]; @@ -24,7 +23,7 @@ const MultiURL = ({name = ['brokerUrls']}: IProps) => ( {fields.map((field, index) => ( - + {!isFirstItem(index) && ( diff --git a/web/src/components/Fields/PlainAuth/Fields.tsx b/web/src/components/Fields/PlainAuth/Fields.tsx index fce6259126..c1842f392e 100644 --- a/web/src/components/Fields/PlainAuth/Fields.tsx +++ b/web/src/components/Fields/PlainAuth/Fields.tsx @@ -1,7 +1,6 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; import * as S from './PlainAuth.styled'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { baseName: string[]; @@ -17,7 +16,7 @@ const Fields = ({baseName}: IProps) => ( rules={[{required: true}]} style={{flexBasis: '50%', overflow: 'hidden'}} > - + ( rules={[{required: true}]} style={{flexBasis: '50%', overflow: 'hidden'}} > - + diff --git a/web/src/components/Fields/URL/URL.tsx b/web/src/components/Fields/URL/URL.tsx index 69546d47c5..aee4ef6b65 100644 --- a/web/src/components/Fields/URL/URL.tsx +++ b/web/src/components/Fields/URL/URL.tsx @@ -1,7 +1,7 @@ import {Col, Form, Row, Select} from 'antd'; import {HTTP_METHOD} from 'constants/Common.constants'; -import {SupportedEditors} from 'constants/Editor.constants'; -import {Editor, DockerTip} from 'components/Inputs'; +import {DockerTip} from 'components/Inputs'; +import SingleLine from '../../Inputs/SingleLine'; interface IProps { showMethodSelector?: boolean; @@ -37,7 +37,7 @@ const URL = ({showMethodSelector = true}: IProps) => ( rules={[{required: true, message: 'Please enter a valid URL'}]} style={{marginBottom: 0}} > - + diff --git a/web/src/components/Fields/VariableName/VariableName.tsx b/web/src/components/Fields/VariableName/VariableName.tsx index cc9dc10b55..8f23aa6433 100644 --- a/web/src/components/Fields/VariableName/VariableName.tsx +++ b/web/src/components/Fields/VariableName/VariableName.tsx @@ -1,6 +1,5 @@ import {Form} from 'antd'; -import {Editor} from 'components/Inputs'; -import {SupportedEditors} from 'constants/Editor.constants'; +import SingleLine from '../../Inputs/SingleLine'; const VariableName = () => ( ( rules={[{required: true, message: 'Please enter a valid variable name'}]} style={{marginBottom: 0}} > - + ); diff --git a/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts index 981aef66b5..ea25aaebb0 100644 --- a/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts +++ b/web/src/components/Inputs/Editor/Expression/hooks/useAutoComplete.ts @@ -1,10 +1,9 @@ import {useCallback} from 'react'; -import {noop, uniqBy} from 'lodash'; +import {noop} from 'lodash'; import {Completion, CompletionContext} from '@codemirror/autocomplete'; import {useAppStore} from 'redux/hooks'; -import AssertionSelectors from 'selectors/Assertion.selectors'; import VariableSetSelectors from 'selectors/VariableSet.selectors'; -import SpanSelectors from 'selectors/Span.selectors'; +import {selectExpressionAttributeList} from 'selectors/Editor.selectors'; import EditorService from 'services/Editor.service'; import {SupportedEditors} from 'constants/Editor.constants'; @@ -18,13 +17,10 @@ interface IProps { const useAutoComplete = ({testId, runId, onSelect = noop, autocompleteCustomValues}: IProps) => { const {getState} = useAppStore(); - const getAttributeList = useCallback(() => { - const state = getState(); - const spanIdList = SpanSelectors.selectMatchedSpans(state); - const attributeList = AssertionSelectors.selectAttributeList(state, testId, runId, spanIdList); - - return uniqBy(attributeList, 'key'); - }, [getState, runId, testId]); + const getAttributeList = useCallback( + () => selectExpressionAttributeList(getState(), testId, runId), + [getState, runId, testId] + ); const getSelectedVariableSetEntryList = useCallback(() => { const state = getState(); diff --git a/web/src/components/Inputs/Editor/Selector/Selector.tsx b/web/src/components/Inputs/Editor/Selector/Selector.tsx index 960c52a277..fd29cab398 100644 --- a/web/src/components/Inputs/Editor/Selector/Selector.tsx +++ b/web/src/components/Inputs/Editor/Selector/Selector.tsx @@ -1,5 +1,6 @@ import {autocompletion} from '@codemirror/autocomplete'; import {linter} from '@codemirror/lint'; +import {EditorState} from '@codemirror/state'; import {EditorView} from '@codemirror/view'; import CodeMirror from '@uiw/react-codemirror'; import {useMemo} from 'react'; @@ -31,7 +32,13 @@ const Selector = ({ const editorTheme = useEditorTheme(); const extensionList = useMemo( - () => [autocompletion({override: [completionFn]}), linter(lintFn), selectorQL(), EditorView.lineWrapping], + () => [ + autocompletion({override: [completionFn]}), + linter(lintFn), + selectorQL(), + EditorView.lineWrapping, + EditorState.transactionFilter.of(tr => (tr.newDoc.lines > 1 ? [] : tr)), + ], [completionFn, lintFn] ); @@ -39,7 +46,7 @@ const Selector = ({ { const {getState} = useAppStore(); - const getAttributeList = useCallback(() => { - const state = getState(); - const defaultList = AssertionSelectors.selectAllAttributeList(state, testId, runId); - - return defaultList; - }, [getState, runId, testId]); + const getAttributeList = useCallback( + () => selectSelectorAttributeList(getState(), testId, runId), + [getState, runId, testId] + ); return useCallback( async (context: CompletionContext) => { @@ -55,7 +53,9 @@ const useAutoComplete = ({testId, runId}: IProps) => { const uniqueList = uniqBy(attributeList, 'key'); const identifierText = state.doc.sliceString(nodeBefore.from, nodeBefore.to); const isIdentifier = nodeBefore.name === Tokens.Identifier; - const list = isIdentifier ? uniqueList.filter(({key}) => key.toLowerCase().includes(identifierText.toLowerCase())) : uniqueList; + const list = isIdentifier + ? uniqueList.filter(({key}) => key.toLowerCase().includes(identifierText.toLowerCase())) + : uniqueList; return { from: isIdentifier ? nodeBefore.from : word.from, diff --git a/web/src/components/Inputs/SingleLine/SingleLine.tsx b/web/src/components/Inputs/SingleLine/SingleLine.tsx new file mode 100644 index 0000000000..c6c540a982 --- /dev/null +++ b/web/src/components/Inputs/SingleLine/SingleLine.tsx @@ -0,0 +1,12 @@ +import {EditorState} from '@codemirror/state'; +import {Editor} from 'components/Inputs'; +import {SupportedEditors} from 'constants/Editor.constants'; +import {IEditorProps} from '../Editor/Editor'; + +const extensions = [EditorState.transactionFilter.of(tr => (tr.newDoc.lines > 1 ? [] : tr))]; + +const SingleLine = (props: IEditorProps) => ( + +); + +export default SingleLine; diff --git a/web/src/components/Inputs/SingleLine/index.ts b/web/src/components/Inputs/SingleLine/index.ts new file mode 100644 index 0000000000..42ff2f26a5 --- /dev/null +++ b/web/src/components/Inputs/SingleLine/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-restricted-exports +export {default} from './SingleLine'; diff --git a/web/src/components/LoadingSpinner/LoadingSpinner.styled.ts b/web/src/components/LoadingSpinner/LoadingSpinner.styled.ts new file mode 100644 index 0000000000..04c78890e0 --- /dev/null +++ b/web/src/components/LoadingSpinner/LoadingSpinner.styled.ts @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const SpinnerContainer = styled.div` + height: 100%; + display: flex; + align-items: center; + justify-content: center; +`; diff --git a/web/src/components/LoadingSpinner/index.ts b/web/src/components/LoadingSpinner/index.ts index 6e7f1055ec..b2e5482e70 100644 --- a/web/src/components/LoadingSpinner/index.ts +++ b/web/src/components/LoadingSpinner/index.ts @@ -1,2 +1,6 @@ +import {SpinnerContainer} from './LoadingSpinner.styled'; + +export {SpinnerContainer}; + // eslint-disable-next-line no-restricted-exports export {default} from './LoadingSpinner'; diff --git a/web/src/components/RunDetailLayout/HeaderLeft.tsx b/web/src/components/RunDetailLayout/HeaderLeft.tsx index e719fd5c3b..a52e6f039b 100644 --- a/web/src/components/RunDetailLayout/HeaderLeft.tsx +++ b/web/src/components/RunDetailLayout/HeaderLeft.tsx @@ -3,13 +3,13 @@ import {useDashboard} from 'providers/Dashboard/Dashboard.provider'; import {useTest} from 'providers/Test/Test.provider'; import {useTestRun} from 'providers/TestRun/TestRun.provider'; import {useMemo} from 'react'; -import Date from 'utils/Date'; import {isRunStateFinished} from 'models/TestRun.model'; import {TDraftTest} from 'types/Test.types'; import TestService from 'services/Test.service'; import HeaderForm from './HeaderForm'; import Info from './Info'; import * as S from './RunDetailLayout.styled'; +import TestRunService from '../../services/TestRun.service'; interface IProps { name: string; @@ -18,21 +18,8 @@ interface IProps { } const HeaderLeft = ({name, triggerType, origin}: IProps) => { - const { - run: { - createdAt, - testSuiteId, - testSuiteRunId, - executionTime, - trace, - traceId, - testVersion, - metadata: {source} = {}, - } = {}, - run, - } = useTestRun(); + const {run: {createdAt, testSuiteId, testSuiteRunId, executionTime, trace, traceId} = {}, run} = useTestRun(); const {onEdit, isEditLoading: isLoading, test} = useTest(); - const createdTimeAgo = Date.getTimeAgo(createdAt ?? ''); const {navigate} = useDashboard(); const stateIsFinished = isRunStateFinished(run.state); @@ -44,7 +31,7 @@ const HeaderLeft = ({name, triggerType, origin}: IProps) => { const description = useMemo(() => { return ( <> - v{testVersion} • {triggerType} • Ran {createdTimeAgo} {source && <>• Run via {source.toUpperCase()}} + {TestRunService.getHeaderInfo(run, triggerType)} {testSuiteId && !!testSuiteRunId && ( <> {' '} @@ -56,7 +43,7 @@ const HeaderLeft = ({name, triggerType, origin}: IProps) => { )} ); - }, [testVersion, triggerType, createdTimeAgo, source, testSuiteId, testSuiteRunId]); + }, [run, triggerType, testSuiteId, testSuiteRunId]); return ( diff --git a/web/src/components/RunDetailTest/TestDAG.tsx b/web/src/components/RunDetailTest/TestDAG.tsx new file mode 100644 index 0000000000..3597fe4893 --- /dev/null +++ b/web/src/components/RunDetailTest/TestDAG.tsx @@ -0,0 +1,62 @@ +import {useCallback, useEffect} from 'react'; +import {Node, NodeChange} from 'react-flow-renderer'; + +import DAG from 'components/Visualization/components/DAG'; +import {useSpan} from 'providers/Span/Span.provider'; +import {useAppDispatch, useAppSelector} from 'redux/hooks'; +import {initNodes, onNodesChange as onNodesChangeAction} from 'redux/slices/DAG.slice'; +import DAGSelectors from 'selectors/DAG.selectors'; +import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; +import Trace from 'models/Trace.model'; +import {useTestSpecForm} from '../TestSpecForm/TestSpecForm.provider'; +import LoadingSpinner, {SpinnerContainer} from '../LoadingSpinner'; + +export interface IProps { + trace: Trace; + onNavigateToSpan(spanId: string): void; +} + +const TestDAG = ({trace: {spans}, onNavigateToSpan}: IProps) => { + const dispatch = useAppDispatch(); + const edges = useAppSelector(DAGSelectors.selectEdges); + const nodes = useAppSelector(DAGSelectors.selectNodes); + const {onSelectSpan, matchedSpans, focusedSpan} = useSpan(); + const {isOpen} = useTestSpecForm(); + + useEffect(() => { + dispatch(initNodes({spans})); + }, [dispatch, spans]); + + const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(onNodesChangeAction({changes})), [dispatch]); + + const onNodeClick = useCallback( + (event, {id}: Node) => { + TraceDiagramAnalyticsService.onClickSpan(id); + onSelectSpan(id); + }, + [onSelectSpan] + ); + + if (spans.length && !nodes.length) { + return ( + + + + ); + } + + return ( + 0 || isOpen} + matchedSpans={matchedSpans} + nodes={nodes} + onNavigateToSpan={onNavigateToSpan} + onNodesChange={onNodesChange} + onNodeClick={onNodeClick} + selectedSpan={focusedSpan} + /> + ); +}; + +export default TestDAG; diff --git a/web/src/components/RunDetailTest/TestPanel.tsx b/web/src/components/RunDetailTest/TestPanel.tsx index 2c7ba26238..e7b3b19dd8 100644 --- a/web/src/components/RunDetailTest/TestPanel.tsx +++ b/web/src/components/RunDetailTest/TestPanel.tsx @@ -1,7 +1,7 @@ import {Tabs} from 'antd'; import {useCallback, useState} from 'react'; import {useSearchParams} from 'react-router-dom'; -import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; +import {VisualizationType, getIsDAGDisabled} from 'components/RunDetailTrace/RunDetailTrace'; import TestOutputs from 'components/TestOutputs'; import TestOutputForm from 'components/TestOutputForm/TestOutputForm'; import TestResults from 'components/TestResults'; @@ -56,7 +56,11 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { const { test: {skipTraceCollection}, } = useTest(); - const [visualizationType, setVisualizationType] = useState(VisualizationType.Dag); + + const isDAGDisabled = getIsDAGDisabled(run?.trace?.spans?.length); + const [visualizationType, setVisualizationType] = useState(() => + isDAGDisabled ? VisualizationType.Timeline : VisualizationType.Dag + ); const handleClose = useCallback(() => { onSetFocusedSpan(''); @@ -111,20 +115,23 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { {run.state === TestState.FINISHED && ( { TestRunAnalytics.onSwitchDiagramView(type); setVisualizationType(type); }} type={visualizationType} + totalSpans={run?.trace?.spans?.length} /> )} {skipTraceCollection && } @@ -217,7 +224,6 @@ const TestPanel = ({run, testId, runEvents}: IProps) => { onDelete={handleDelete} onEdit={handleEdit} onRevert={handleRevert} - onSelectSpan={handleSelectSpan} selectedSpan={selectedSpan?.id} testSpec={selectedTestSpec} /> diff --git a/web/src/components/RunDetailTest/Visualization.tsx b/web/src/components/RunDetailTest/Visualization.tsx index 7e41e954f4..6ea97b2b23 100644 --- a/web/src/components/RunDetailTest/Visualization.tsx +++ b/web/src/components/RunDetailTest/Visualization.tsx @@ -1,66 +1,32 @@ import {useCallback, useEffect} from 'react'; -import {Node, NodeChange} from 'react-flow-renderer'; import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; import RunEvents from 'components/RunEvents'; -import {useTestSpecForm} from 'components/TestSpecForm/TestSpecForm.provider'; -import DAG from 'components/Visualization/components/DAG'; -import Timeline from 'components/Visualization/components/Timeline'; +import TimelineV2 from 'components/Visualization/components/Timeline/TimelineV2'; import {TestRunStage} from 'constants/TestRunEvents.constants'; import {NodeTypesEnum} from 'constants/Visualization.constants'; -import Span from 'models/Span.model'; import TestRunEvent from 'models/TestRunEvent.model'; import {useSpan} from 'providers/Span/Span.provider'; -import {useAppDispatch, useAppSelector} from 'redux/hooks'; -import {initNodes, onNodesChange as onNodesChangeAction} from 'redux/slices/DAG.slice'; -import DAGSelectors from 'selectors/DAG.selectors'; -import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; -import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; +import Trace from 'models/Trace.model'; import TestRunService from 'services/TestRun.service'; import {TTestRunState} from 'types/TestRun.types'; +import TestDAG from './TestDAG'; export interface IProps { + isDAGDisabled: boolean; runEvents: TestRunEvent[]; runState: TTestRunState; - spans: Span[]; type: VisualizationType; + trace: Trace; } -const Visualization = ({runEvents, runState, spans, type}: IProps) => { - const dispatch = useAppDispatch(); - const edges = useAppSelector(DAGSelectors.selectEdges); - const nodes = useAppSelector(DAGSelectors.selectNodes); - const {onSelectSpan, matchedSpans, onSetFocusedSpan, focusedSpan, selectedSpan} = useSpan(); - - const {isOpen} = useTestSpecForm(); - - useEffect(() => { - dispatch(initNodes({spans})); - }, [dispatch, spans]); +const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { + const {onSelectSpan, matchedSpans, onSetFocusedSpan, selectedSpan} = useSpan(); useEffect(() => { if (selectedSpan) return; - const firstSpan = spans.find(span => !span.parentId); - onSelectSpan(firstSpan?.id ?? ''); - }, [onSelectSpan, selectedSpan, spans]); - - const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(onNodesChangeAction({changes})), [dispatch]); - - const onNodeClick = useCallback( - (event, {id}: Node) => { - TraceDiagramAnalyticsService.onClickSpan(id); - onSelectSpan(id); - }, - [onSelectSpan] - ); - - const onNodeClickTimeline = useCallback( - (spanId: string) => { - TraceAnalyticsService.onTimelineSpanClick(spanId); - onSelectSpan(spanId); - }, - [onSelectSpan] - ); + onSelectSpan(rootSpan.id); + }, [onSelectSpan, rootSpan, selectedSpan, spans]); const onNavigateToSpan = useCallback( (spanId: string) => { @@ -74,24 +40,14 @@ const Visualization = ({runEvents, runState, spans, type}: IProps) => { return ; } - return type === VisualizationType.Dag ? ( - 0 || isOpen} - matchedSpans={matchedSpans} - nodes={nodes} - onNavigateToSpan={onNavigateToSpan} - onNodesChange={onNodesChange} - onNodeClick={onNodeClick} - selectedSpan={focusedSpan} - /> + return type === VisualizationType.Dag && !isDAGDisabled ? ( + ) : ( - 0 || isOpen} + diff --git a/web/src/components/RunDetailTrace/AnalyzerPanel.tsx b/web/src/components/RunDetailTrace/AnalyzerPanel.tsx index e8da58fa9a..af25b789d4 100644 --- a/web/src/components/RunDetailTrace/AnalyzerPanel.tsx +++ b/web/src/components/RunDetailTrace/AnalyzerPanel.tsx @@ -1,5 +1,4 @@ import TestRun, {isRunStateFinished} from 'models/TestRun.model'; -import Trace from 'models/Trace.model'; import AnalyzerResult from '../AnalyzerResult/AnalyzerResult'; import SkeletonResponse from '../RunDetailTriggerResponse/SkeletonResponse'; import {RightPanel, PanelContainer} from '../ResizablePanels'; @@ -19,11 +18,7 @@ const AnalyzerPanel = ({run}: IProps) => ( {size => ( - {isRunStateFinished(run.state) ? ( - - ) : ( - - )} + {isRunStateFinished(run.state) ? : } )} diff --git a/web/src/components/RunDetailTrace/RunDetailTrace.tsx b/web/src/components/RunDetailTrace/RunDetailTrace.tsx index 3333e8ec22..5953b75b25 100644 --- a/web/src/components/RunDetailTrace/RunDetailTrace.tsx +++ b/web/src/components/RunDetailTrace/RunDetailTrace.tsx @@ -1,4 +1,5 @@ import ResizablePanels from 'components/ResizablePanels'; +import {MAX_DAG_NODES} from 'constants/Visualization.constants'; import TestRun from 'models/TestRun.model'; import TestRunEvent from 'models/TestRunEvent.model'; import * as S from './RunDetailTrace.styled'; @@ -19,6 +20,10 @@ export enum VisualizationType { Timeline, } +export function getIsDAGDisabled(totalSpans: number = 0): boolean { + return totalSpans > MAX_DAG_NODES; +} + const RunDetailTrace = ({run, runEvents, testId, skipTraceCollection}: IProps) => { return ( diff --git a/web/src/components/RunDetailTrace/Search.tsx b/web/src/components/RunDetailTrace/Search.tsx index 0f33e2cb83..5e04dfd2a6 100644 --- a/web/src/components/RunDetailTrace/Search.tsx +++ b/web/src/components/RunDetailTrace/Search.tsx @@ -5,16 +5,13 @@ import {useCallback, useMemo, useState} from 'react'; import {Editor} from 'components/Inputs'; import {SupportedEditors} from 'constants/Editor.constants'; -import {useTestRun} from 'providers/TestRun/TestRun.provider'; import TracetestAPI from 'redux/apis/Tracetest'; import {useAppDispatch, useAppSelector} from 'redux/hooks'; import {matchSpans, selectSpan, setSearchText} from 'redux/slices/Trace.slice'; import TraceSelectors from 'selectors/Trace.selectors'; -import SpanService from 'services/Span.service'; -import EditorService from 'services/Editor.service'; import * as S from './RunDetailTrace.styled'; -const {useLazyGetSelectedSpansQuery} = TracetestAPI.instance; +const {useGetSearchedSpansMutation} = TracetestAPI.instance; interface IProps { runId: number; @@ -25,35 +22,25 @@ const Search = ({runId, testId}: IProps) => { const [search, setSearch] = useState(''); const dispatch = useAppDispatch(); const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); - const { - run: {trace: {spans = []} = {}}, - } = useTestRun(); - const [getSelectedSpans] = useLazyGetSelectedSpansQuery(); + const [getSearchedSpans] = useGetSearchedSpansMutation(); const handleSearch = useCallback( async (query: string) => { - const isValidSelector = EditorService.getIsQueryValid(SupportedEditors.Selector, query || ''); if (!query) { dispatch(matchSpans({spanIds: []})); dispatch(selectSpan({spanId: ''})); return; } - let spanIds = []; - if (isValidSelector) { - const selectedSpansData = await getSelectedSpans({query, runId, testId}).unwrap(); - spanIds = selectedSpansData.spanIds; - } else { - dispatch(setSearchText({searchText: query})); - spanIds = SpanService.searchSpanList(spans, query); - } - + const {spanIds} = await getSearchedSpans({query, runId, testId}).unwrap(); + dispatch(setSearchText({searchText: query})); dispatch(matchSpans({spanIds})); + if (spanIds.length) { dispatch(selectSpan({spanId: spanIds[0]})); } }, - [dispatch, getSelectedSpans, runId, spans, testId] + [dispatch, getSearchedSpans, runId, testId] ); const onSearch = useMemo(() => debounce(handleSearch, 500), [handleSearch]); @@ -67,7 +54,7 @@ const Search = ({runId, testId}: IProps) => { { onSearch(query); setSearch(query); diff --git a/web/src/components/RunDetailTrace/TraceDAG.tsx b/web/src/components/RunDetailTrace/TraceDAG.tsx new file mode 100644 index 0000000000..a874852440 --- /dev/null +++ b/web/src/components/RunDetailTrace/TraceDAG.tsx @@ -0,0 +1,61 @@ +import {useAppDispatch, useAppSelector} from 'redux/hooks'; +import TraceSelectors from 'selectors/Trace.selectors'; +import {Node, NodeChange} from 'react-flow-renderer'; +import {changeNodes, initNodes, selectSpan} from 'redux/slices/Trace.slice'; +import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; +import {useCallback, useEffect} from 'react'; +import Trace from 'models/Trace.model'; +import DAG from '../Visualization/components/DAG'; +import LoadingSpinner, {SpinnerContainer} from '../LoadingSpinner'; + +interface IProps { + trace: Trace; + onNavigateToSpan(spanId: string): void; + matchedSpans: string[]; + selectedSpan: string; +} + +const TraceDAG = ({trace: {spans}, matchedSpans, selectedSpan, onNavigateToSpan}: IProps) => { + const nodes = useAppSelector(TraceSelectors.selectNodes); + const edges = useAppSelector(TraceSelectors.selectEdges); + const isMatchedMode = Boolean(matchedSpans.length); + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(initNodes({spans})); + }, [dispatch, spans]); + + const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(changeNodes({changes})), [dispatch]); + + const onNodeClick = useCallback( + (event: React.MouseEvent, {id}: Node) => { + event.stopPropagation(); + TraceDiagramAnalyticsService.onClickSpan(id); + dispatch(selectSpan({spanId: id})); + }, + [dispatch] + ); + + if (spans.length && !nodes.length) { + return ( + + + + ); + } + + return ( + + ); +}; + +export default TraceDAG; diff --git a/web/src/components/RunDetailTrace/TracePanel.tsx b/web/src/components/RunDetailTrace/TracePanel.tsx index 53cc6edb8f..ed958e3f09 100644 --- a/web/src/components/RunDetailTrace/TracePanel.tsx +++ b/web/src/components/RunDetailTrace/TracePanel.tsx @@ -4,7 +4,7 @@ import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; import {TestState} from 'constants/TestRun.constants'; import TestRunEvent from 'models/TestRunEvent.model'; import Search from './Search'; -import {VisualizationType} from './RunDetailTrace'; +import {VisualizationType, getIsDAGDisabled} from './RunDetailTrace'; import * as S from './RunDetailTrace.styled'; import Switch from '../Visualization/components/Switch/Switch'; import Visualization from './Visualization'; @@ -19,7 +19,10 @@ type TProps = { }; const TracePanel = ({run, testId, runEvents, skipTraceCollection}: TProps) => { - const [visualizationType, setVisualizationType] = useState(VisualizationType.Dag); + const isDAGDisabled = getIsDAGDisabled(run?.trace?.spans?.length); + const [visualizationType, setVisualizationType] = useState(() => + isDAGDisabled ? VisualizationType.Timeline : VisualizationType.Dag + ); return ( @@ -34,18 +37,21 @@ const TracePanel = ({run, testId, runEvents, skipTraceCollection}: TProps) => { {run.state === TestState.FINISHED && ( { TraceAnalyticsService.onSwitchDiagramView(type); setVisualizationType(type); }} type={visualizationType} + totalSpans={run?.trace?.spans?.length} /> )} diff --git a/web/src/components/RunDetailTrace/Visualization.tsx b/web/src/components/RunDetailTrace/Visualization.tsx index 3ec49e140a..b2a52f9135 100644 --- a/web/src/components/RunDetailTrace/Visualization.tsx +++ b/web/src/components/RunDetailTrace/Visualization.tsx @@ -3,64 +3,43 @@ import {TestRunStage} from 'constants/TestRunEvents.constants'; import {NodeTypesEnum} from 'constants/Visualization.constants'; import TestRunEvent from 'models/TestRunEvent.model'; import {useCallback, useEffect} from 'react'; -import {Node, NodeChange} from 'react-flow-renderer'; import {useAppDispatch, useAppSelector} from 'redux/hooks'; -import {changeNodes, initNodes, selectSpan} from 'redux/slices/Trace.slice'; +import {selectSpan} from 'redux/slices/Trace.slice'; import TraceSelectors from 'selectors/Trace.selectors'; -import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; -import TraceDiagramAnalyticsService from 'services/Analytics/TraceDiagramAnalytics.service'; import TestRunService from 'services/TestRun.service'; +import Trace from 'models/Trace.model'; import {TTestRunState} from 'types/TestRun.types'; -import Span from 'models/Span.model'; -import DAG from '../Visualization/components/DAG'; -import Timeline from '../Visualization/components/Timeline'; +import TimelineV2 from 'components/Visualization/components/Timeline/TimelineV2'; import {VisualizationType} from './RunDetailTrace'; +import TraceDAG from './TraceDAG'; interface IProps { + isDAGDisabled: boolean; runEvents: TestRunEvent[]; runState: TTestRunState; - spans: Span[]; + trace: Trace; type: VisualizationType; } -const Visualization = ({runEvents, runState, spans, type}: IProps) => { +const Visualization = ({isDAGDisabled, runEvents, runState, trace, trace: {spans, rootSpan}, type}: IProps) => { const dispatch = useAppDispatch(); - const edges = useAppSelector(TraceSelectors.selectEdges); - const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); - const nodes = useAppSelector(TraceSelectors.selectNodes); const selectedSpan = useAppSelector(TraceSelectors.selectSelectedSpan); - const isMatchedMode = Boolean(matchedSpans.length); - - useEffect(() => { - dispatch(initNodes({spans})); - }, [dispatch, spans]); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); useEffect(() => { if (selectedSpan) return; - const firstSpan = spans.find(span => !span.parentId); - dispatch(selectSpan({spanId: firstSpan?.id ?? ''})); - }, [dispatch, selectedSpan, spans]); - - const onNodesChange = useCallback((changes: NodeChange[]) => dispatch(changeNodes({changes})), [dispatch]); - const onNodeClick = useCallback( - (event: React.MouseEvent, {id}: Node) => { - event.stopPropagation(); - TraceDiagramAnalyticsService.onClickSpan(id); - dispatch(selectSpan({spanId: id})); - }, - [dispatch] - ); + dispatch(selectSpan({spanId: rootSpan.id ?? ''})); + }, [dispatch, rootSpan.id, selectedSpan, spans]); - const onNodeClickTimeline = useCallback( + const onNavigateToSpan = useCallback( (spanId: string) => { - TraceAnalyticsService.onTimelineSpanClick(spanId); dispatch(selectSpan({spanId})); }, [dispatch] ); - const onNavigateToSpan = useCallback( + const onNodeClickTimeline = useCallback( (spanId: string) => { dispatch(selectSpan({spanId})); }, @@ -71,26 +50,21 @@ const Visualization = ({runEvents, runState, spans, type}: IProps) => { return ; } - return type === VisualizationType.Dag ? ( - ) : ( - ); }; diff --git a/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx b/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx index 951ad2da29..83b3be1d64 100644 --- a/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx +++ b/web/src/components/TestPlugins/Forms/Kafka/Kafka.tsx @@ -6,6 +6,7 @@ import useQueryTabs from 'hooks/useQueryTabs'; import {SupportedEditors} from 'constants/Editor.constants'; import {TDraftTest} from 'types/Test.types'; import * as S from './Kafka.styled'; +import SingleLine from '../../../Inputs/SingleLine'; const Kafka = () => { const [activeKey, setActiveKey] = useQueryTabs('auth', 'triggerTab'); @@ -25,7 +26,7 @@ const Kafka = () => { } key="message"> - + { } key="topic"> - + diff --git a/web/src/components/TestResults/TestResults.tsx b/web/src/components/TestResults/TestResults.tsx index 77968585b9..715d768982 100644 --- a/web/src/components/TestResults/TestResults.tsx +++ b/web/src/components/TestResults/TestResults.tsx @@ -31,7 +31,7 @@ const TestResults = ({onDelete, onEdit, onRevert}: IProps) => { onSelectSpan(testSpec?.spanIds[0] || ''); setSelectedSpec(testSpec?.selector); }, - [assertionResults?.resultList, onSelectSpan, onSetFocusedSpan, setSelectedSpec] + [assertionResults, onSelectSpan, onSetFocusedSpan, setSelectedSpec] ); return ( diff --git a/web/src/components/TestSpec/TestSpec.styled.ts b/web/src/components/TestSpec/TestSpec.styled.ts index 28b70e3683..dd5a301bef 100644 --- a/web/src/components/TestSpec/TestSpec.styled.ts +++ b/web/src/components/TestSpec/TestSpec.styled.ts @@ -17,6 +17,7 @@ export const Container = styled.div<{$isDeleted: boolean}>` display: flex; gap: 12px; padding: 16px; + margin-bottom: 16px; > div:first-child { opacity: ${({$isDeleted}) => ($isDeleted ? 0.5 : 1)}; diff --git a/web/src/components/TestSpecDetail/Content.tsx b/web/src/components/TestSpecDetail/Content.tsx index 90dea1340c..4ad20d8bcf 100644 --- a/web/src/components/TestSpecDetail/Content.tsx +++ b/web/src/components/TestSpecDetail/Content.tsx @@ -1,24 +1,21 @@ -import {useEffect, useMemo} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; +import {FixedSizeList as List} from 'react-window'; +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; -import {SemanticGroupNames} from 'constants/SemanticGroupNames.constants'; -import {useTestRun} from 'providers/TestRun/TestRun.provider'; import {useAppSelector} from 'redux/hooks'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; import AssertionService from 'services/Assertion.service'; +import TraceSelectors from 'selectors/Trace.selectors'; import {TAssertionResultEntry} from 'models/AssertionResults.model'; -import {useTest} from 'providers/Test/Test.provider'; -import useScrollTo from 'hooks/useScrollTo'; -import Assertion from './Assertion'; import Header from './Header'; -import SpanHeader from './SpanHeader'; -import * as S from './TestSpecDetail.styled'; +import ResultCard from './ResultCard'; +import Search from './Search'; interface IProps { onClose(): void; onDelete(selector: string): void; onEdit(assertionResult: TAssertionResultEntry, name: string): void; onRevert(originalSelector: string): void; - onSelectSpan(spanId: string): void; selectedSpan?: string; testSpec: TAssertionResultEntry; } @@ -28,17 +25,10 @@ const Content = ({ onDelete, onEdit, onRevert, - onSelectSpan, selectedSpan, testSpec, testSpec: {resultList, selector, spanIds}, }: IProps) => { - const { - run: {trace, id: runId}, - } = useTestRun(); - const { - test: {id: testId}, - } = useTest(); const { isDeleted = false, isDraft = false, @@ -46,12 +36,28 @@ const Content = ({ name = '', } = useAppSelector(state => TestSpecsSelectors.selectSpecBySelector(state, selector)) || {}; const totalPassedChecks = useMemo(() => AssertionService.getTotalPassedChecks(resultList), [resultList]); - const results = useMemo(() => AssertionService.getResultsHashedBySpanId(resultList), [resultList]); - const scrollTo = useScrollTo(); + const matchedSpans = useAppSelector(TraceSelectors.selectMatchedSpans); + const results = useMemo( + () => Object.entries(AssertionService.getResultsHashedBySpanId(resultList, matchedSpans)), + [matchedSpans, resultList] + ); + + const listRef = useRef(null); useEffect(() => { - scrollTo(`assertion-result-${selectedSpan}`); - }, [scrollTo, selectedSpan]); + if (listRef.current) { + const index = results.findIndex(([spanId]) => spanId === selectedSpan); + if (index !== -1) { + listRef?.current?.scrollToItem(index, 'smart'); + } + } + }, [results, selectedSpan]); + + const itemSize = useMemo(() => { + const [, checkResults = []] = results[0]; + + return checkResults.length * 72.59 + 40 + 16; + }, [results]); return ( <> @@ -76,33 +82,22 @@ const Content = ({ title={!selector && !name ? 'All Spans' : name} /> - {Object.entries(results).map(([spanId, checkResults]) => { - const span = trace?.spans.find(({id}) => id === spanId); + - return ( - } - type="inner" - $isSelected={spanId === selectedSpan} - $type={span?.type ?? SemanticGroupNames.General} - id={`assertion-result-${spanId}`} + + {({height, width}: Size) => ( + - onSelectSpan(span?.id ?? '')}> - {checkResults.map(checkResult => ( - - ))} - - - ); - })} + {ResultCard} + + )} + ); }; diff --git a/web/src/components/TestSpecDetail/ResultCard.tsx b/web/src/components/TestSpecDetail/ResultCard.tsx new file mode 100644 index 0000000000..95f4b33876 --- /dev/null +++ b/web/src/components/TestSpecDetail/ResultCard.tsx @@ -0,0 +1,65 @@ +import {useCallback, useMemo} from 'react'; +import {useSpan} from 'providers/Span/Span.provider'; +import {useTest} from 'providers/Test/Test.provider'; +import {ICheckResult} from 'types/Assertion.types'; +import {SemanticGroupNames} from 'constants/SemanticGroupNames.constants'; +import {useTestRun} from 'providers/TestRun/TestRun.provider'; +import * as S from './TestSpecDetail.styled'; +import Assertion from './Assertion'; +import SpanHeader from './SpanHeader'; + +interface IProps { + index: number; + data: [string, ICheckResult[]][]; + style: React.CSSProperties; +} + +const ResultCard = ({index, data, style}: IProps) => { + const [spanId, checkResults] = useMemo(() => data[index], [data, index]); + const { + run: {trace, id: runId}, + } = useTestRun(); + const { + test: {id: testId}, + } = useTest(); + const {selectedSpan, onSetFocusedSpan, onSelectSpan} = useSpan(); + + const onFocusAndSelect = useCallback(() => { + onSelectSpan(spanId); + onSetFocusedSpan(spanId); + }, [onSelectSpan, onSetFocusedSpan, spanId]); + + const span = trace?.flat[spanId]; + + return ( + } + type="inner" + $isSelected={spanId === selectedSpan?.id} + $type={span?.type ?? SemanticGroupNames.General} + id={`assertion-result-${spanId}`} + onClick={() => onSelectSpan(span?.id ?? '')} + > + + {checkResults.map(checkResult => ( + + ))} + + + ); +}; + +export default ResultCard; diff --git a/web/src/components/TestSpecDetail/Search.tsx b/web/src/components/TestSpecDetail/Search.tsx new file mode 100644 index 0000000000..539d9333e5 --- /dev/null +++ b/web/src/components/TestSpecDetail/Search.tsx @@ -0,0 +1,70 @@ +import {Col} from 'antd'; +import {debounce} from 'lodash'; +import {useCallback, useMemo, useState} from 'react'; + +import {Editor} from 'components/Inputs'; +import {SupportedEditors} from 'constants/Editor.constants'; +import TracetestAPI from 'redux/apis/Tracetest'; +import {useTestRun} from 'providers/TestRun/TestRun.provider'; +import {useTest} from 'providers/Test/Test.provider'; +import {useAppDispatch} from 'redux/hooks'; +import {matchSpans, selectSpan, setSearchText} from 'redux/slices/Trace.slice'; +import * as S from './TestSpecDetail.styled'; + +const {useGetSearchedSpansMutation} = TracetestAPI.instance; + +const Search = () => { + const [search, setSearch] = useState(''); + const dispatch = useAppDispatch(); + const [getSearchedSpans] = useGetSearchedSpansMutation(); + const { + run: {id: runId}, + } = useTestRun(); + const { + test: {id: testId}, + } = useTest(); + + const handleSearch = useCallback( + async (query: string) => { + if (!query) { + dispatch(matchSpans({spanIds: []})); + dispatch(selectSpan({spanId: ''})); + return; + } + + const {spanIds} = await getSearchedSpans({query, runId, testId}).unwrap(); + dispatch(setSearchText({searchText: query})); + dispatch(matchSpans({spanIds})); + + if (spanIds.length) { + dispatch(selectSpan({spanId: spanIds[0]})); + } + }, + [dispatch, getSearchedSpans, runId, testId] + ); + + const onSearch = useMemo(() => debounce(handleSearch, 500), [handleSearch]); + const onClear = useCallback(() => { + onSearch(''); + setSearch(''); + }, [onSearch]); + + return ( + + + { + onSearch(query); + setSearch(query); + }} + value={search} + /> + {!!search && } + + + ); +}; + +export default Search; diff --git a/web/src/components/TestSpecDetail/SpanHeader.tsx b/web/src/components/TestSpecDetail/SpanHeader.tsx index 03ed113014..4817ca3d92 100644 --- a/web/src/components/TestSpecDetail/SpanHeader.tsx +++ b/web/src/components/TestSpecDetail/SpanHeader.tsx @@ -1,5 +1,6 @@ import {SettingOutlined, ToolOutlined} from '@ant-design/icons'; +import {Typography} from 'antd'; import * as SSpanNode from 'components/Visualization/components/DAG/BaseSpanNode/BaseSpanNode.styled'; import {SemanticGroupNamesToText} from 'constants/SemanticGroupNames.constants'; import {SpanKindToText} from 'constants/Span.constants'; @@ -16,20 +17,25 @@ const SpanHeader = ({onSelectSpan, span}: IProps) => { const {kind, name, service, system, type} = SpanService.getSpanInfo(span); return ( - onSelectSpan(span?.id ?? '')}> - - {name} - - - {`${service} ${SpanKindToText[kind]}`} - - {Boolean(system) && ( + + onSelectSpan(span?.id ?? '')}> + + {name} - - {system} + + {`${service} ${SpanKindToText[kind]}`} - )} - + {Boolean(system) && ( + + + {system} + + )} + + + {span?.id} + + ); }; diff --git a/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts b/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts index a1b2841500..24b00e82fc 100644 --- a/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts +++ b/web/src/components/TestSpecDetail/TestSpecDetail.styled.ts @@ -1,4 +1,4 @@ -import {CheckCircleFilled, InfoCircleFilled, MinusCircleFilled} from '@ant-design/icons'; +import {CheckCircleFilled, CloseCircleFilled, InfoCircleFilled, MinusCircleFilled} from '@ant-design/icons'; import {Card, Drawer, Typography} from 'antd'; import styled from 'styled-components'; @@ -18,10 +18,6 @@ export const CardContainer = styled(Card)<{$isSelected: boolean; $type: Semantic border: ${({$isSelected, theme}) => $isSelected ? `1px solid ${theme.color.interactive}` : `1px solid ${theme.color.borderLight}`}; - :not(:last-child) { - margin-bottom: 16px; - } - .ant-card-head { border-bottom: ${({theme}) => `1px solid ${theme.color.borderLight}`}; border-top: ${({$type}) => `4px solid ${SemanticGroupNamesToColor[$type]}`}; @@ -108,5 +104,24 @@ export const SpanHeaderContainer = styled.div` cursor: pointer; display: flex; gap: 8px; +`; + +export const Wrapper = styled.div` + align-items: center; + cursor: pointer; + justify-content: space-between; + display: flex; padding: 8px 12px; `; + +export const ClearSearchIcon = styled(CloseCircleFilled)` + position: absolute; + right: 8px; + top: 8px; + color: ${({theme}) => theme.color.textLight}; + cursor: pointer; +`; + +export const SearchContainer = styled(Row)` + margin-bottom: 16px; +`; diff --git a/web/src/components/TestSpecDetail/TestSpecDetail.tsx b/web/src/components/TestSpecDetail/TestSpecDetail.tsx index 8141068bd8..4a7adc3f40 100644 --- a/web/src/components/TestSpecDetail/TestSpecDetail.tsx +++ b/web/src/components/TestSpecDetail/TestSpecDetail.tsx @@ -8,45 +8,32 @@ interface IProps { onDelete(selector: string): void; onEdit(assertionResult: TAssertionResultEntry, name: string): void; onRevert(originalSelector: string): void; - onSelectSpan(spanId: string): void; selectedSpan?: string; testSpec?: TAssertionResultEntry; } -const TestSpecDetail = ({ - isOpen, - onClose, - onDelete, - onEdit, - onRevert, - onSelectSpan, - selectedSpan, - testSpec, -}: IProps) => { - return ( - - {testSpec && ( - - )} - - ); -}; +const TestSpecDetail = ({isOpen, onClose, onDelete, onEdit, onRevert, selectedSpan, testSpec}: IProps) => ( + + {testSpec && ( + + )} + +); export default TestSpecDetail; diff --git a/web/src/components/TestSpecs/TestSpecs.styled.ts b/web/src/components/TestSpecs/TestSpecs.styled.ts index 17cebb4d5b..25433c3f84 100644 --- a/web/src/components/TestSpecs/TestSpecs.styled.ts +++ b/web/src/components/TestSpecs/TestSpecs.styled.ts @@ -3,12 +3,6 @@ import styled from 'styled-components'; import noResultsIcon from 'assets/SpanAssertionsEmptyState.svg'; -export const Container = styled.div` - display: flex; - flex-direction: column; - gap: 16px; -`; - export const EmptyContainer = styled.div` align-items: center; display: flex; diff --git a/web/src/components/TestSpecs/TestSpecs.tsx b/web/src/components/TestSpecs/TestSpecs.tsx index 034b972e38..83aa70b175 100644 --- a/web/src/components/TestSpecs/TestSpecs.tsx +++ b/web/src/components/TestSpecs/TestSpecs.tsx @@ -1,7 +1,8 @@ import TestSpec from 'components/TestSpec'; +import AutoSizer, {Size} from 'react-virtualized-auto-sizer'; +import {FixedSizeList as List} from 'react-window'; import AssertionResults, {TAssertionResultEntry} from 'models/AssertionResults.model'; import Empty from './Empty'; -import * as S from './TestSpecs.styled'; interface IProps { assertionResults?: AssertionResults; @@ -17,20 +18,32 @@ const TestSpecs = ({assertionResults, onDelete, onEdit, onOpen, onRevert}: IProp } return ( - - {assertionResults?.resultList?.map(specResult => - specResult.resultList.length ? ( - - ) : null + + {({height, width}: Size) => ( + + {({index, data}) => { + const specResult = data[index]; + + return specResult.resultList.length ? ( + + ) : null; + }} + )} - + ); }; diff --git a/web/src/components/Visualization/components/DAG/DAG.tsx b/web/src/components/Visualization/components/DAG/DAG.tsx index f7a4fe77a9..c719c23643 100644 --- a/web/src/components/Visualization/components/DAG/DAG.tsx +++ b/web/src/components/Visualization/components/DAG/DAG.tsx @@ -5,6 +5,7 @@ import Actions from './Actions'; import * as S from './DAG.styled'; import TestSpanNode from './TestSpanNode/TestSpanNode'; import TraceSpanNode from './TraceSpanNode/TraceSpanNode'; +import {MAX_DAG_NODES} from '../../../../constants/Visualization.constants'; /** Important to define the nodeTypes outside the component to prevent re-renderings */ const nodeTypes = {traceSpan: TraceSpanNode, testSpan: TestSpanNode}; @@ -46,15 +47,17 @@ const DAG = ({ edges={edges} nodes={nodes} deleteKeyCode={null} - fitView minZoom={0.1} multiSelectionKeyCode={null} nodesConnectable={false} nodeTypes={nodeTypes} + onInit={() => nodes.length >= MAX_DAG_NODES && onNavigateToSpan(nodes[0]?.id)} onNodeClick={onNodeClick} onNodeDragStop={onNodeClick} onNodesChange={onNodesChange} + onlyRenderVisibleElements selectionKeyCode={null} + fitView={nodes.length <= MAX_DAG_NODES} > {isMiniMapActive && } diff --git a/web/src/components/Visualization/components/Navigation/Navigation.tsx b/web/src/components/Visualization/components/Navigation/Navigation.tsx index 8517c217e8..c4ba60e0f4 100644 --- a/web/src/components/Visualization/components/Navigation/Navigation.tsx +++ b/web/src/components/Visualization/components/Navigation/Navigation.tsx @@ -13,6 +13,7 @@ interface IProps { } const Navigation = ({matchedSpans, onNavigateToSpan, selectedSpan}: IProps) => { + // TODO: save matched spans in a different data structure const index = matchedSpans.findIndex(spanId => spanId === selectedSpan) + 1; const navigate = useCallback( diff --git a/web/src/components/Visualization/components/Switch/Switch.styled.ts b/web/src/components/Visualization/components/Switch/Switch.styled.ts index 4cc51f89ba..6e4067bf0a 100644 --- a/web/src/components/Visualization/components/Switch/Switch.styled.ts +++ b/web/src/components/Visualization/components/Switch/Switch.styled.ts @@ -11,10 +11,13 @@ export const Container = styled.div` padding: 7px; `; -export const DAGIcon = styled(ClusterOutlined)<{$isSelected?: boolean}>` +export const DAGIcon = styled(ClusterOutlined)<{$isDisabled?: boolean; $isSelected?: boolean}>` color: ${({$isSelected = false, theme}) => ($isSelected ? theme.color.primary : theme.color.textSecondary)}; - cursor: pointer; font-size: ${({theme}) => theme.size.xl}; + + && { + cursor: ${({$isDisabled}) => ($isDisabled ? 'not-allowed' : 'pointer')}; + } `; export const TimelineIcon = styled(BarsOutlined)<{$isSelected?: boolean}>` diff --git a/web/src/components/Visualization/components/Switch/Switch.tsx b/web/src/components/Visualization/components/Switch/Switch.tsx index 4e4fc4e68c..26458eb11f 100644 --- a/web/src/components/Visualization/components/Switch/Switch.tsx +++ b/web/src/components/Visualization/components/Switch/Switch.tsx @@ -1,17 +1,30 @@ import {Tooltip} from 'antd'; - import {VisualizationType} from 'components/RunDetailTrace/RunDetailTrace'; +import {MAX_DAG_NODES} from 'constants/Visualization.constants'; import * as S from './Switch.styled'; interface IProps { + isDAGDisabled: boolean; onChange(type: VisualizationType): void; type: VisualizationType; + totalSpans?: number; } -const Switch = ({onChange, type}: IProps) => ( +const Switch = ({isDAGDisabled, onChange, type, totalSpans = 0}: IProps) => ( - - onChange(VisualizationType.Dag)} /> + + !isDAGDisabled && onChange(VisualizationType.Dag)} + /> 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/Header.tsx b/web/src/components/Visualization/components/Timeline/Header.tsx new file mode 100644 index 0000000000..d5ff7cdc41 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Header.tsx @@ -0,0 +1,22 @@ +import Ticks from './Ticks/Ticks'; +import * as S from './TimelineV2.styled'; + +const NUM_TICKS = 5; + +interface IProps { + duration: number; +} + +const Header = ({duration}: IProps) => ( + + + + Span + + + + + +); + +export default Header; diff --git a/web/src/components/Visualization/components/Timeline/ListWrapper.tsx b/web/src/components/Visualization/components/Timeline/ListWrapper.tsx new file mode 100644 index 0000000000..cda954bfb7 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/ListWrapper.tsx @@ -0,0 +1,33 @@ +import {FixedSizeList as List} from 'react-window'; +import Header from './Header'; +import SpanNodeFactory from './SpanNodeFactoryV2'; +import * as S from './TimelineV2.styled'; +import {useTimeline} from './Timeline.provider'; + +const HEADER_HEIGHT = 242; + +interface IProps { + listRef: React.RefObject; +} + +const ListWrapper = ({listRef}: IProps) => { + const {spans, viewEnd, viewStart} = useTimeline(); + + return ( + +
    + + {SpanNodeFactory} + + + ); +}; + +export default ListWrapper; diff --git a/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx b/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx new file mode 100644 index 0000000000..9f3c92ca47 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/NavigationWrapper.tsx @@ -0,0 +1,10 @@ +import Navigation from '../Navigation'; +import {useTimeline} from './Timeline.provider'; + +const NavigationWrapper = () => { + const {matchedSpans, onSpanNavigation, selectedSpan} = useTimeline(); + + return ; +}; + +export default NavigationWrapper; 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/Ticks/Ticks.styled.ts b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts new file mode 100644 index 0000000000..0af2ed4f32 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.styled.ts @@ -0,0 +1,37 @@ +import {Typography} from 'antd'; +import styled, {css} from 'styled-components'; + +export const Ticks = styled.div` + pointer-events: none; + position: relative; +`; + +export const Tick = styled.div` + align-items: center; + background: ${({theme}) => theme.color.borderLight}; + display: flex; + height: 100%; + position: absolute; + width: 1px; + + :first-child, + :last-child { + width: 0; + } +`; + +export const TickLabel = styled(Typography.Text)<{$isEndAnchor: boolean}>` + color: ${({theme}) => theme.color.text}; + font-size: ${({theme}) => theme.size.sm}; + font-weight: 400; + left: 0.25rem; + position: absolute; + white-space: nowrap; + + ${({$isEndAnchor}) => + $isEndAnchor && + css` + left: initial; + right: 0.25rem; + `}; +`; diff --git a/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx new file mode 100644 index 0000000000..5387421b91 --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Ticks/Ticks.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import Date, {ONE_MILLISECOND} from 'utils/Date'; +import * as S from './Ticks.styled'; + +function getLabels(numTicks: number, startTime: number, endTime: number) { + const viewingDuration = endTime - startTime; + const labels = []; + + for (let i = 0; i < numTicks; i += 1) { + const durationAtTick = startTime + (i / (numTicks - 1)) * viewingDuration; + labels.push(Date.formatDuration(durationAtTick * ONE_MILLISECOND)); + } + + return labels; +} + +interface IProps { + endTime?: number; + numTicks: number; + startTime?: number; +} + +const Ticks = ({endTime = 0, numTicks, startTime = 0}: IProps) => { + const labels = getLabels(numTicks, startTime, endTime); + + return ( + + {labels.map((label, index) => { + const portion = index / (numTicks - 1); + return ( + + = 1}>{label} + + ); + })} + + ); +}; + +export default Ticks; diff --git a/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx new file mode 100644 index 0000000000..26257e2e6b --- /dev/null +++ b/web/src/components/Visualization/components/Timeline/Timeline.provider.tsx @@ -0,0 +1,127 @@ +import {NodeTypesEnum} from 'constants/Visualization.constants'; +import noop from 'lodash/noop'; +import without from 'lodash/without'; +import Span from 'models/Span.model'; +import TimelineModel from 'models/Timeline.model'; +import {createContext, useCallback, useContext, useMemo, useState} from 'react'; +import {FixedSizeList as List} from 'react-window'; +import TraceAnalyticsService from 'services/Analytics/TestRunAnalytics.service'; +import TimelineService, {TScaleFunction} from 'services/Timeline.service'; +import {TNode} from 'types/Timeline.types'; + +interface IContext { + collapsedSpans: string[]; + getScale: TScaleFunction; + matchedSpans: string[]; + onSpanClick(spanId: string): void; + onSpanCollapse(spanId: string): void; + onSpanNavigation(spanId: string): void; + selectedSpan: string; + spans: TNode[]; + viewEnd: number; + viewStart: number; +} + +export const Context = createContext({ + collapsedSpans: [], + getScale: () => ({start: 0, end: 0}), + matchedSpans: [], + onSpanClick: noop, + onSpanCollapse: noop, + onSpanNavigation: noop, + selectedSpan: '', + spans: [], + viewEnd: 0, + viewStart: 0, +}); + +interface IProps { + children: React.ReactNode; + listRef: React.RefObject; + nodeType: NodeTypesEnum; + spans: Span[]; + onNavigate(spanId: string): void; + onClick(spanId: string): void; + matchedSpans: string[]; + selectedSpan: string; +} + +export const useTimeline = () => useContext(Context); + +const TimelineProvider = ({ + children, + listRef, + nodeType, + spans, + onClick, + onNavigate, + matchedSpans, + selectedSpan, +}: IProps) => { + const [collapsedSpans, setCollapsedSpans] = useState([]); + + const nodes = useMemo(() => TimelineModel(spans, nodeType), [spans, nodeType]); + const filteredNodes = useMemo(() => TimelineService.getFilteredNodes(nodes, collapsedSpans), [collapsedSpans, nodes]); + const [min, max] = useMemo(() => TimelineService.getMinMax(nodes), [nodes]); + const getScale = useCallback(() => TimelineService.createScaleFunc({min, max}), [max, min]); + + const onSpanClick = useCallback( + (spanId: string) => { + TraceAnalyticsService.onTimelineSpanClick(spanId); + onClick(spanId); + }, + [onClick] + ); + + const onSpanNavigation = useCallback( + (spanId: string) => { + onNavigate(spanId); + // TODO: Improve the method to search for the index + const index = filteredNodes.findIndex(node => node.data.id === spanId); + if (index !== -1) { + listRef?.current?.scrollToItem(index, 'start'); + } + }, + [filteredNodes, listRef, onNavigate] + ); + + const onSpanCollapse = useCallback((spanId: string) => { + setCollapsedSpans(prevCollapsed => { + if (prevCollapsed.includes(spanId)) { + return without(prevCollapsed, spanId); + } + return [...prevCollapsed, spanId]; + }); + }, []); + + const value = useMemo( + () => ({ + collapsedSpans, + getScale: getScale(), + matchedSpans, + onSpanClick, + onSpanCollapse, + onSpanNavigation, + selectedSpan, + spans: filteredNodes, + viewEnd: max, + viewStart: min, + }), + [ + collapsedSpans, + filteredNodes, + getScale, + matchedSpans, + max, + min, + onSpanClick, + onSpanCollapse, + onSpanNavigation, + selectedSpan, + ] + ); + + return {children}; +}; + +export default TimelineProvider; 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; diff --git a/web/src/constants/Timeline.constants.ts b/web/src/constants/Timeline.constants.ts index a8c3a1c480..4a4677dafd 100644 --- a/web/src/constants/Timeline.constants.ts +++ b/web/src/constants/Timeline.constants.ts @@ -3,3 +3,4 @@ export const AxisOffset = 100; export const NodeHeight = 66; export const NodeOverlayHeight = NodeHeight - 2; export const BaseLeftPadding = 10; +export const BaseLeftPaddingV2 = 16; diff --git a/web/src/constants/Visualization.constants.ts b/web/src/constants/Visualization.constants.ts index b1030ac6e9..9146f4a20d 100644 --- a/web/src/constants/Visualization.constants.ts +++ b/web/src/constants/Visualization.constants.ts @@ -2,3 +2,5 @@ export enum NodeTypesEnum { TraceSpan = 'traceSpan', TestSpan = 'testSpan', } + +export const MAX_DAG_NODES = 200; diff --git a/web/src/hooks/useSpanData.ts b/web/src/hooks/useSpanData.ts index 799bce5a07..a8ff64654d 100644 --- a/web/src/hooks/useSpanData.ts +++ b/web/src/hooks/useSpanData.ts @@ -28,6 +28,8 @@ const useSpanData = (id: string): IUseSpanData => { const span = useAppSelector(state => selectSpanById(state, {testId, runId, spanId: id})); + // TODO: should we get analyzerErrors, testSpecs and testOutputs as part of the trace struct from the BE? + // Right now we are getting them from the testRun struct for each span by spanId const analyzerErrors = useAppSelector(state => selectAnalyzerErrorsBySpanId(state, {testId, runId, spanId: id})); const testSpecs = useAppSelector(state => selectTestSpecsBySpanId(state, {testId, runId, spanId: id})); diff --git a/web/src/models/DAG.model.ts b/web/src/models/DAG.model.ts index 38248f6d71..d1d4ea3598 100644 --- a/web/src/models/DAG.model.ts +++ b/web/src/models/DAG.model.ts @@ -19,7 +19,10 @@ function DAG(spans: Span[], type: NodeTypesEnum) { if (b.id > a.id) return 1; return 0; }); + return DAGService.getEdgesAndNodes(nodesDatum); } +export const getShouldShowDAG = (spanCount: number): boolean => spanCount <= 200; + export default DAG; diff --git a/web/src/models/SearchSpansResult.model.ts b/web/src/models/SearchSpansResult.model.ts new file mode 100644 index 0000000000..affc074b76 --- /dev/null +++ b/web/src/models/SearchSpansResult.model.ts @@ -0,0 +1,22 @@ +import {Model, TTestSchemas} from '../types/Common.types'; + +export type TRawSearchSpansResult = TTestSchemas['SearchSpansResult']; +type SearchSpansResult = Model< + TRawSearchSpansResult, + { + spanIds: string[]; + spansIds?: undefined; + } +>; + +const defaultSearchSpansResult: TRawSearchSpansResult = { + spansIds: [], +}; + +function SearchSpansResult({spansIds = []} = defaultSearchSpansResult): SearchSpansResult { + return { + spanIds: spansIds, + }; +} + +export default SearchSpansResult; diff --git a/web/src/models/Span.model.ts b/web/src/models/Span.model.ts index 37f9c4cbae..580f156b82 100644 --- a/web/src/models/Span.model.ts +++ b/web/src/models/Span.model.ts @@ -48,7 +48,16 @@ const getSpanSignature = ( }, []); }; -const Span = ({id = '', attributes = {}, startTime = 0, endTime = 0, parentId = '', name = ''}: TRawSpan): Span => { +const defaultSpan: TRawSpan = { + id: '', + parentId: '', + name: '', + attributes: {}, + startTime: 0, + endTime: 0, +}; + +const Span = ({id = '', attributes = {}, startTime = 0, endTime = 0, parentId = '', name = ''} = defaultSpan): Span => { const mappedAttributeList: TSpanFlatAttribute[] = [{key: 'name', value: name}]; const attributeList = Object.entries(attributes) .map(([key, value]) => ({ diff --git a/web/src/models/TestRun.model.ts b/web/src/models/TestRun.model.ts index 4e39fedef8..e4dbcc3678 100644 --- a/web/src/models/TestRun.model.ts +++ b/web/src/models/TestRun.model.ts @@ -20,7 +20,7 @@ type TestRun = Model< TRawTestRun, { result: AssertionResults; - trace?: Trace; + trace: Trace; totalAssertionCount: number; failedAssertionCount: number; passedAssertionCount: number; @@ -138,7 +138,7 @@ const TestRun = ({ spanId, state, testVersion, - trace: trace ? Trace(trace) : undefined, + trace: trace ? Trace(trace) : Trace(), totalAssertionCount: getTestResultCount(result), failedAssertionCount: getTestResultCount(result, 'failed'), passedAssertionCount: getTestResultCount(result, 'passed'), diff --git a/web/src/models/Trace.model.ts b/web/src/models/Trace.model.ts index 5fabfab841..b0cdc8f036 100644 --- a/web/src/models/Trace.model.ts +++ b/web/src/models/Trace.model.ts @@ -1,16 +1,35 @@ -import { TTraceSchemas } from 'types/Common.types'; +import {TTraceSchemas} from 'types/Common.types'; import Span from './Span.model'; export type TRawTrace = TTraceSchemas['Trace']; +export type TSpanMap = Record; type Trace = { + flat: TSpanMap; spans: Span[]; traceId: string; + rootSpan: Span; }; -const Trace = ({traceId = '', flat = {}}: TRawTrace): Trace => { +const defaultTrace: TRawTrace = { + traceId: '', + flat: {}, + tree: {}, +}; + +const Trace = ({traceId = '', flat: rawFlat = {}, tree = {}} = defaultTrace): Trace => { + const flat: TSpanMap = {}; + const spans = Object.values(rawFlat).map(raw => { + const span = Span(raw); + flat[span.id || ''] = span; + + return span; + }); + return { traceId, - spans: Object.values(flat).map(rawSpan => Span(rawSpan)), + rootSpan: Span(tree), + flat, + spans, }; }; diff --git a/web/src/models/__tests__/TestRun.model.test.ts b/web/src/models/__tests__/TestRun.model.test.ts index 91c806fb08..2aa1012335 100644 --- a/web/src/models/__tests__/TestRun.model.test.ts +++ b/web/src/models/__tests__/TestRun.model.test.ts @@ -7,7 +7,6 @@ describe('Test Run', () => { const testRunResult = TestRun(rawTestRunResult); expect(testRunResult.id).toEqual(rawTestRunResult.id); - expect(testRunResult.trace).not.toEqual(undefined); expect(testRunResult.totalAssertionCount).toEqual(0); expect(testRunResult.passedAssertionCount).toEqual(0); expect(testRunResult.failedAssertionCount).toEqual(0); @@ -21,7 +20,6 @@ describe('Test Run', () => { const testRunResult = TestRun(rawTestRunResult); - expect(testRunResult.trace).toEqual(undefined); expect(testRunResult.executionTime).toEqual(0); }); }); diff --git a/web/src/providers/TestRun/TestRun.provider.tsx b/web/src/providers/TestRun/TestRun.provider.tsx index 57a378658d..169d8d5801 100644 --- a/web/src/providers/TestRun/TestRun.provider.tsx +++ b/web/src/providers/TestRun/TestRun.provider.tsx @@ -5,6 +5,7 @@ import TestRun, {isRunStateFinished} from 'models/TestRun.model'; import TestRunEvent from 'models/TestRunEvent.model'; import TracetestAPI from 'redux/apis/Tracetest'; import TestProvider from '../Test'; +import LoadingSpinner, { SpinnerContainer } from '../../components/LoadingSpinner'; const {useGetRunByIdQuery, useGetRunEventsQuery, useStopRunMutation, useSkipPollingMutation} = TracetestAPI.instance; @@ -76,7 +77,9 @@ const TestRunProvider = ({children, testId, runId = 0}: IProps) => { ) : ( -
    + + + ); }; diff --git a/web/src/providers/TestSpecs/TestSpecs.provider.tsx b/web/src/providers/TestSpecs/TestSpecs.provider.tsx index 42e8fe4420..ac4179c0e2 100644 --- a/web/src/providers/TestSpecs/TestSpecs.provider.tsx +++ b/web/src/providers/TestSpecs/TestSpecs.provider.tsx @@ -60,11 +60,11 @@ const TestSpecsProvider = ({children, testId, runId}: IProps) => { const {test} = useTest(); const {run} = useTestRun(); - const assertionResults = useAppSelector(state => TestSpecsSelectors.selectAssertionResults(state)); - const specs = useAppSelector(state => TestSpecsSelectors.selectSpecs(state)); - const isDraftMode = useAppSelector(state => TestSpecsSelectors.selectIsDraftMode(state)); - const isLoading = useAppSelector(state => TestSpecsSelectors.selectIsLoading(state)); - const isInitialized = useAppSelector(state => TestSpecsSelectors.selectIsInitialized(state)); + const assertionResults = useAppSelector(TestSpecsSelectors.selectAssertionResults); + const specs = useAppSelector(TestSpecsSelectors.selectSpecs); + const isDraftMode = useAppSelector(TestSpecsSelectors.selectIsDraftMode); + const isLoading = useAppSelector(TestSpecsSelectors.selectIsLoading); + const isInitialized = useAppSelector(TestSpecsSelectors.selectIsInitialized); const selectedSpec = useAppSelector(TestSpecsSelectors.selectSelectedSpec); const selectedTestSpec = useAppSelector(state => TestSpecsSelectors.selectAssertionBySelector(state, selectedSpec!)); diff --git a/web/src/redux/actions/Router.actions.ts b/web/src/redux/actions/Router.actions.ts index a7b195d36b..f404f4d99c 100644 --- a/web/src/redux/actions/Router.actions.ts +++ b/web/src/redux/actions/Router.actions.ts @@ -4,7 +4,7 @@ import {Params} from 'react-router-dom'; import {push} from 'redux-first-history'; import {RouterSearchFields} from 'constants/Common.constants'; import TestSpecsSelectors from 'selectors/TestSpecs.selectors'; -import DAGSelectors from 'selectors/DAG.selectors'; +// import DAGSelectors from 'selectors/DAG.selectors'; import SpanSelectors from 'selectors/Span.selectors'; import {setSelectedSpan} from 'redux/slices/Span.slice'; import {setSelectedSpec} from 'redux/slices/TestSpecs.slice'; @@ -31,10 +31,13 @@ const RouterActions = () => ({ getState() as RootState, Number(positionIndex) ); - const isDagReady = DAGSelectors.selectNodes(getState() as RootState).length > 0; + + // TODO: the default view for big traces is no longer the DAG, so this check is no longer valid + // move the view to the state and check depending on the type + // const isViewReady = DAGSelectors.selectNodes(getState() as RootState).length > 0; if (selectedSpec === assertionResult?.selector) return; - if (assertionResult && isDagReady) dispatch(setSelectedSpec(assertionResult)); + if (assertionResult) dispatch(setSelectedSpec(assertionResult)); } ), updateSelectedSpan: createAsyncThunk( diff --git a/web/src/redux/actions/TestSpecs.actions.ts b/web/src/redux/actions/TestSpecs.actions.ts index 7d3dea5399..f6583b91e5 100644 --- a/web/src/redux/actions/TestSpecs.actions.ts +++ b/web/src/redux/actions/TestSpecs.actions.ts @@ -26,6 +26,7 @@ const TestSpecsActions = () => ({ const specs = TestSpecsSelectors.selectSpecs(getState() as RootState).filter(def => !def.isDeleted); const outputs = selectTestOutputs(getState() as RootState); const rawTest = await TestService.getUpdatedRawTest(test, {definition: {specs}, outputs}); + await dispatch(TestGateway.edit(rawTest, testId)); return dispatch(TestRunGateway.reRun(testId, runId)).unwrap(); } diff --git a/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts b/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts index e19a9ff977..61621b1f0b 100644 --- a/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts +++ b/web/src/redux/apis/Tracetest/endpoints/TestRun.endpoint.ts @@ -8,6 +8,7 @@ import SelectedSpans, {TRawSelectedSpans} from 'models/SelectedSpans.model'; import Test from 'models/Test.model'; import TestRun, {TRawTestRun} from 'models/TestRun.model'; import TestRunEvent, {TRawTestRunEvent} from 'models/TestRunEvent.model'; +import SearchSpansResult, {TRawSearchSpansResult} from 'models/SearchSpansResult.model'; import {KnownSources} from 'models/RunMetadata.model'; import {TRawTestSpecs} from 'models/TestSpecs.model'; import {TTestApiEndpointBuilder} from '../Tracetest.api'; @@ -113,6 +114,14 @@ export const testRunEndpoints = (builder: TTestApiEndpointBuilder) => ({ providesTags: (result, error, {query}) => (result ? [{type: TracetestApiTags.SPAN, id: `${query}-LIST`}] : []), transformResponse: (rawSpanList: TRawSelectedSpans) => SelectedSpans(rawSpanList), }), + getSearchedSpans: builder.mutation({ + query: ({query, testId, runId}) => ({ + url: `/tests/${testId}/run/${runId}/search-spans`, + method: HTTP_METHOD.POST, + body: JSON.stringify({query}), + }), + transformResponse: (raw: TRawSearchSpansResult) => SearchSpansResult(raw), + }), getRunEvents: builder.query({ query: ({runId, testId}) => `/tests/${testId}/run/${runId}/events`, diff --git a/web/src/redux/apis/Tracetest/index.ts b/web/src/redux/apis/Tracetest/index.ts index f81596602d..c0c7935bab 100644 --- a/web/src/redux/apis/Tracetest/index.ts +++ b/web/src/redux/apis/Tracetest/index.ts @@ -68,6 +68,7 @@ const { useLazyTestOtlpConnectionQuery, useTestOtlpConnectionQuery, useResetTestOtlpConnectionMutation, + useGetSearchedSpansMutation, endpoints, } = TracetestAPI.instance; @@ -129,5 +130,6 @@ export { useLazyTestOtlpConnectionQuery, useTestOtlpConnectionQuery, useResetTestOtlpConnectionMutation, + useGetSearchedSpansMutation, endpoints, }; diff --git a/web/src/redux/slices/DAG.slice.ts b/web/src/redux/slices/DAG.slice.ts index db55e0aa55..cea4c45145 100644 --- a/web/src/redux/slices/DAG.slice.ts +++ b/web/src/redux/slices/DAG.slice.ts @@ -1,4 +1,4 @@ -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {createAsyncThunk, createSlice, PayloadAction} from '@reduxjs/toolkit'; import {applyNodeChanges, Edge, MarkerType, Node, NodeChange} from 'react-flow-renderer'; import {theme} from 'constants/Theme.constants'; @@ -23,8 +23,7 @@ const dagSlice = createSlice({ name: 'dag', initialState, reducers: { - initNodes(state, {payload}: PayloadAction<{spans: Span[]}>) { - const {edges, nodes} = DAGModel(payload.spans, NodeTypesEnum.TestSpan); + initNodes(state, {payload: {edges, nodes}}: PayloadAction<{edges: Edge[]; nodes: Node[]}>) { state.edges = edges; state.nodes = nodes; }, @@ -78,5 +77,13 @@ const dagSlice = createSlice({ }, }); -export const {initNodes, onNodesChange} = dagSlice.actions; +export const initNodes = createAsyncThunk( + 'dag/generateDagLayout', + async ({spans}, {dispatch}) => { + const {edges, nodes} = await DAGModel(spans, NodeTypesEnum.TestSpan); + dispatch(dagSlice.actions.initNodes({edges, nodes})); + } +); + +export const {onNodesChange} = dagSlice.actions; export default dagSlice.reducer; diff --git a/web/src/redux/slices/Trace.slice.ts b/web/src/redux/slices/Trace.slice.ts index d258bd9032..542c3f7d10 100644 --- a/web/src/redux/slices/Trace.slice.ts +++ b/web/src/redux/slices/Trace.slice.ts @@ -1,4 +1,4 @@ -import {createSlice, PayloadAction} from '@reduxjs/toolkit'; +import {createAsyncThunk, createSlice, PayloadAction} from '@reduxjs/toolkit'; import {applyNodeChanges, Edge, MarkerType, Node, NodeChange} from 'react-flow-renderer'; import {theme} from 'constants/Theme.constants'; @@ -26,10 +26,10 @@ const traceSlice = createSlice({ name: 'trace', initialState, reducers: { - initNodes(state, {payload}: PayloadAction<{spans: Span[]}>) { - const {edges, nodes} = DAGModel(payload.spans, NodeTypesEnum.TraceSpan); + initNodes(state, {payload: {edges, nodes}}: PayloadAction<{edges: Edge[]; nodes: Node[]}>) { state.edges = edges; state.nodes = nodes; + // Clear state state.matchedSpans = []; state.searchText = ''; @@ -70,5 +70,13 @@ const traceSlice = createSlice({ }, }); -export const {initNodes, changeNodes, selectSpan, matchSpans, setSearchText} = traceSlice.actions; +export const initNodes = createAsyncThunk( + 'trace/generateDagLayout', + async ({spans}, {dispatch}) => { + const {edges, nodes} = await DAGModel(spans, NodeTypesEnum.TraceSpan); + dispatch(traceSlice.actions.initNodes({edges, nodes})); + } +); + +export const {changeNodes, selectSpan, matchSpans, setSearchText} = traceSlice.actions; export default traceSlice.reducer; diff --git a/web/src/selectors/Assertion.selectors.ts b/web/src/selectors/Assertion.selectors.ts index 7351d3a2c8..8635b74055 100644 --- a/web/src/selectors/Assertion.selectors.ts +++ b/web/src/selectors/Assertion.selectors.ts @@ -29,7 +29,7 @@ const selectMatchedSpanList = createSelector(stateSelector, paramsSelector, (sta const {data: {trace} = {}} = TracetestAPI.instance.endpoints.getRunById.select({testId, runId})(state); if (!spanIdList.length) return trace?.spans || []; - return trace?.spans.filter(({id}) => spanIdList.includes(id)) || []; + return spanIdList.map((spanId) => trace!.flat[spanId]); }); const AssertionSelectors = () => { diff --git a/web/src/selectors/Editor.selectors.ts b/web/src/selectors/Editor.selectors.ts new file mode 100644 index 0000000000..9f511e96c2 --- /dev/null +++ b/web/src/selectors/Editor.selectors.ts @@ -0,0 +1,26 @@ +import {uniqBy} from 'lodash'; +import {createSelector} from '@reduxjs/toolkit'; +import {RootState} from 'redux/store'; +import AssertionSelectors from './Assertion.selectors'; +import SpanSelectors from './Span.selectors'; + +const stateSelector = (state: RootState) => state; +const paramsSelector = (state: RootState, testId: string, runId: number) => ({ + testId, + runId, +}); + +export const selectSelectorAttributeList = createSelector(stateSelector, paramsSelector, (state, {testId, runId}) => + AssertionSelectors.selectAllAttributeList(state, testId, runId) +); + +export const selectExpressionAttributeList = createSelector( + stateSelector, + paramsSelector, + SpanSelectors.selectMatchedSpans, + (state, {testId, runId}, spanIds) => { + const attributeList = AssertionSelectors.selectAttributeList(state, testId, runId, spanIds); + + return uniqBy(attributeList, 'key'); + } +); diff --git a/web/src/selectors/Span.selectors.ts b/web/src/selectors/Span.selectors.ts index d1dc43f3ad..9e6ecc8cc0 100644 --- a/web/src/selectors/Span.selectors.ts +++ b/web/src/selectors/Span.selectors.ts @@ -23,9 +23,7 @@ const SpanSelectors = () => ({ selectSpanById: createSelector(stateSelector, paramsSelector, (state, {spanId, testId, runId}) => { const {data: {trace} = {}} = TracetestAPI.instance.endpoints.getRunById.select({testId, runId})(state); - const spanList = trace?.spans || []; - - return spanList.find(span => span.id === spanId); + return trace?.flat[spanId]; }), selectSelectedSpan: createSelector(spansStateSelector, ({selectedSpan}) => selectedSpan), selectFocusedSpan: createSelector(spansStateSelector, ({focusedSpan}) => focusedSpan), diff --git a/web/src/selectors/TestRun.selectors.ts b/web/src/selectors/TestRun.selectors.ts index bcc50c2c24..9638ed067f 100644 --- a/web/src/selectors/TestRun.selectors.ts +++ b/web/src/selectors/TestRun.selectors.ts @@ -15,7 +15,7 @@ const selectTestRun = (state: RootState, params: {testId: string; runId: number; export const selectSpanById = createSelector([selectTestRun, selectParams], (testRun, params) => { const {trace} = testRun; - return trace?.spans?.find(span => span.id === params.spanId) ?? Span({id: params.spanId}); + return trace.flat[params.spanId] || Span({id: params.spanId}); }); const selectAnalyzerErrors = createSelector([selectTestRun], testRun => { diff --git a/web/src/services/Analyzer.service.ts b/web/src/services/Analyzer.service.ts index 12600cc319..a91bc39574 100644 --- a/web/src/services/Analyzer.service.ts +++ b/web/src/services/Analyzer.service.ts @@ -3,14 +3,23 @@ import LinterResult from 'models/LinterResult.model'; const MAX_PLUGIN_SCORE = 100; const AnalyzerService = () => ({ - getPlugins(plugins: LinterResult['plugins'], showOnlyErrors: boolean): LinterResult['plugins'] { + getPlugins( + plugins: LinterResult['plugins'], + showOnlyErrors: boolean, + spanIds: string[] = [] + ): LinterResult['plugins'] { return plugins .filter(plugin => !showOnlyErrors || plugin.score < MAX_PLUGIN_SCORE) .map(plugin => ({ ...plugin, rules: plugin.rules .filter(rule => !showOnlyErrors || !rule.passed) - .map(rule => ({...rule, results: rule?.results?.filter(result => !showOnlyErrors || !result.passed)})), + .map(rule => ({ + ...rule, + results: rule.results.filter( + result => (!spanIds.length || spanIds.includes(result.spanId)) && (!showOnlyErrors || !result.passed) + ), + })), })); }, }); diff --git a/web/src/services/Assertion.service.ts b/web/src/services/Assertion.service.ts index dcbbd21d88..cf616dc3da 100644 --- a/web/src/services/Assertion.service.ts +++ b/web/src/services/Assertion.service.ts @@ -53,9 +53,10 @@ const AssertionService = () => ({ .some(result => !!result); }, - getResultsHashedBySpanId(resultList: AssertionResult[]) { + getResultsHashedBySpanId(resultList: AssertionResult[], spanIds: string[] = []) { return resultList .flatMap(({assertion, spanResults}) => spanResults.map(spanResult => ({result: spanResult, assertion}))) + .filter(({result}) => !spanIds.length || spanIds.includes(result.spanId)) .reduce((prev: Record, curr) => { const items = prev[curr.result.spanId] || []; items.push(curr); diff --git a/web/src/services/DAG.service.ts b/web/src/services/DAG.service.ts index c8ec9358e6..e1fb1a7050 100644 --- a/web/src/services/DAG.service.ts +++ b/web/src/services/DAG.service.ts @@ -1,10 +1,11 @@ import {coordCenter, Dag, dagStratify, layeringSimplex, sugiyama} from 'd3-dag'; -import {MarkerType} from 'react-flow-renderer'; +import {Edge, MarkerType, Node} from 'react-flow-renderer'; import {theme} from 'constants/Theme.constants'; import {INodeDatum} from 'types/DAG.types'; +import {withLowPriority} from '../utils/Common'; -function getDagLayout(nodesDatum: INodeDatum[]) { +function getDagLayout(nodesDatum: INodeDatum[]): Dag, undefined> { const stratify = dagStratify(); const dag = stratify(nodesDatum); @@ -18,7 +19,7 @@ function getDagLayout(nodesDatum: INodeDatum[]) { return dag; } -function getNodes(dagLayout: Dag, undefined>) { +function getNodes(dagLayout: Dag, undefined>): Node[] { return dagLayout.descendants().map(({data: {id, data, type}, x, y}) => ({ data, id, @@ -27,7 +28,7 @@ function getNodes(dagLayout: Dag, undefined>) { })); } -function getEdges(dagLayout: Dag, undefined>) { +function getEdges(dagLayout: Dag, undefined>): Edge[] { return dagLayout.links().map(({source, target}) => ({ animated: false, id: `${source.data.id}-${target.data.id}`, @@ -39,12 +40,12 @@ function getEdges(dagLayout: Dag, undefined>) { } const DAGService = () => ({ - getEdgesAndNodes(nodesDatum: INodeDatum[]) { + async getEdgesAndNodes(nodesDatum: INodeDatum[]): Promise<{edges: Edge[]; nodes: Node[]}> { if (!nodesDatum.length) return {edges: [], nodes: []}; - const dagLayout = getDagLayout(nodesDatum); - const edges = getEdges(dagLayout); - const nodes = getNodes(dagLayout); + const dagLayout = await withLowPriority(getDagLayout)(nodesDatum); + const edges = await withLowPriority(getEdges)(dagLayout); + const nodes = await withLowPriority(getNodes)(dagLayout); return {edges, nodes}; }, diff --git a/web/src/services/Span.service.ts b/web/src/services/Span.service.ts index 1325e2d3e2..c4a439f851 100644 --- a/web/src/services/Span.service.ts +++ b/web/src/services/Span.service.ts @@ -45,6 +45,7 @@ const SpanService = () => ({ ).trim()}]`; }, + // TODO: this is very costly, we might need to move this to the backend searchSpanList(spanList: Span[], searchText: string) { if (!searchText.trim()) return []; diff --git a/web/src/services/TestRun.service.ts b/web/src/services/TestRun.service.ts index e0ad41303f..503b3b8aad 100644 --- a/web/src/services/TestRun.service.ts +++ b/web/src/services/TestRun.service.ts @@ -2,10 +2,12 @@ import {filter, findLastIndex, flow} from 'lodash'; import {TestRunStage, TraceEventType} from 'constants/TestRunEvents.constants'; import AssertionResults from 'models/AssertionResults.model'; import LinterResult from 'models/LinterResult.model'; -import {isRunStateAnalyzingError, isRunStateStopped, isRunStateSucceeded} from 'models/TestRun.model'; +import TestRun, {isRunStateAnalyzingError, isRunStateStopped, isRunStateSucceeded} from 'models/TestRun.model'; import TestRunEvent from 'models/TestRunEvent.model'; import TestRunOutput from 'models/TestRunOutput.model'; import {TAnalyzerErrorsBySpan, TTestOutputsBySpan, TTestRunState, TTestSpecsBySpan} from 'types/TestRun.types'; +import Date from 'utils/Date'; +import {singularOrPlural} from 'utils/Common'; const TestRunService = () => ({ shouldDisplayTraceEvents(state: TTestRunState, numberOfSpans: number) { @@ -96,6 +98,14 @@ const TestRunService = () => ({ return {...prev, [curr.spanId]: [...value, curr]}; }, {}); }, + + getHeaderInfo({createdAt, testVersion, metadata: {source = ''}, trace}: TestRun, triggerType: string) { + const createdTimeAgo = Date.getTimeAgo(createdAt ?? ''); + + return `v${testVersion} • ${triggerType} • Ran ${createdTimeAgo} • ${ + !!trace?.spans.length && `${trace.spans.length} ${singularOrPlural('span', trace?.spans.length)}` + } ${source && `• Run via ${source.toUpperCase()}`}`; + }, }); export default TestRunService(); diff --git a/web/src/services/Timeline.service.ts b/web/src/services/Timeline.service.ts index 610f389dab..8b06096135 100644 --- a/web/src/services/Timeline.service.ts +++ b/web/src/services/Timeline.service.ts @@ -2,6 +2,8 @@ import {stratify} from '@visx/hierarchy'; import {NodeTypesEnum} from 'constants/Visualization.constants'; import {INodeDataSpan, TNode} from 'types/Timeline.types'; +export type TScaleFunction = (start: number, end: number) => {start: number; end: number}; + function getHierarchyNodes(nodesData: INodeDataSpan[]) { return stratify() .id(d => d.id) @@ -48,6 +50,22 @@ const TimelineService = () => ({ const endTimes = nodes.map(node => node.data.endTime); return [Math.min(...startTimes), Math.max(...endTimes)]; }, + + createScaleFunc(viewRange: {min: number; max: number}): TScaleFunction { + const {min, max} = viewRange; + const viewWindow = max - min; + + /** + * Scale function + * @param {number} start The start of the sub-range. + * @param {number} end The end of the sub-range. + * @return {Object} The resultant range. + */ + return (start: number, end: number) => ({ + start: (start - min) / viewWindow, + end: (end - min) / viewWindow, + }); + }, }); export default TimelineService(); diff --git a/web/src/utils/Common.ts b/web/src/utils/Common.ts index 6cdc3dab52..ef2c86c1e9 100644 --- a/web/src/utils/Common.ts +++ b/web/src/utils/Common.ts @@ -87,3 +87,12 @@ export const getParsedURL = (rawUrl: string): URL => { return new URL(rawUrl); }; + +export const withLowPriority = + any>(fn: T): ((...args: Parameters) => Promise>) => + (...args: Parameters): Promise> => + new Promise(resolve => { + setTimeout(() => { + resolve(fn(...args)); + }, 0); + }); diff --git a/web/src/utils/Date.ts b/web/src/utils/Date.ts index d534d4dbe3..19c73d2433 100644 --- a/web/src/utils/Date.ts +++ b/web/src/utils/Date.ts @@ -1,4 +1,21 @@ import {format, formatDistanceToNowStrict, isValid, parseISO} from 'date-fns'; +import dropWhile from 'lodash/dropWhile'; +import round from 'lodash/round'; + +export const ONE_MILLISECOND = 1000 * 1; +const ONE_SECOND = 1000 * ONE_MILLISECOND; +const ONE_MINUTE = 60 * ONE_SECOND; +const ONE_HOUR = 60 * ONE_MINUTE; +const ONE_DAY = 24 * ONE_HOUR; + +const UNIT_STEPS: {unit: string; microseconds: number; ofPrevious: number}[] = [ + {unit: 'd', microseconds: ONE_DAY, ofPrevious: 24}, + {unit: 'h', microseconds: ONE_HOUR, ofPrevious: 60}, + {unit: 'm', microseconds: ONE_MINUTE, ofPrevious: 60}, + {unit: 's', microseconds: ONE_SECOND, ofPrevious: 1000}, + {unit: 'ms', microseconds: ONE_MILLISECOND, ofPrevious: 1000}, + {unit: 'μs', microseconds: 1, ofPrevious: 1000}, +]; const Date = { format(date: string, dateFormat = "EEEE, yyyy/MM/dd 'at' HH:mm:ss") { @@ -8,6 +25,7 @@ const Date = { } return format(isoDate, dateFormat); }, + getTimeAgo(date: string) { const isoDate = parseISO(date); if (!isValid(isoDate)) { @@ -15,9 +33,35 @@ const Date = { } return formatDistanceToNowStrict(isoDate, {addSuffix: true}); }, + isDefaultDate(date: string) { return date === '0001-01-01T00:00:00Z'; }, + + /** + * Format duration for display. + * + * @param {number} duration - microseconds + * @return {string} formatted duration + */ + formatDuration(duration: number): string { + // Drop all units that are too large except the last one + const [primaryUnit, secondaryUnit] = dropWhile( + UNIT_STEPS, + ({microseconds}, index) => index < UNIT_STEPS.length - 1 && microseconds > duration + ); + + if (primaryUnit.ofPrevious === 1000) { + // If the unit is decimal based, display as a decimal + return `${round(duration / primaryUnit.microseconds, 2)}${primaryUnit.unit}`; + } + + const primaryValue = Math.floor(duration / primaryUnit.microseconds); + const primaryUnitString = `${primaryValue}${primaryUnit.unit}`; + const secondaryValue = Math.round((duration / secondaryUnit.microseconds) % primaryUnit.ofPrevious); + const secondaryUnitString = `${secondaryValue}${secondaryUnit.unit}`; + return secondaryValue === 0 ? primaryUnitString : `${primaryUnitString} ${secondaryUnitString}`; + }, }; export default Date;