From 9c9febdea97e1d07df72b4a7f490392c5f4ad179 Mon Sep 17 00:00:00 2001 From: rosstimothy <39066650+rosstimothy@users.noreply.github.com> Date: Wed, 6 Dec 2023 17:34:50 -0500 Subject: [PATCH] Detect and report latency measurements for SSH sessions in the UI (#34862) Monitors both the UI<->Proxy and Proxy<->SSH host connections in order to provide users with near real time latency data for ssh connections established via the UI. The client portion of the connection is measured by how long it takes to receive a web socket pong in response to sending a web socket ping. The host portion of the connection is measured by how long it takes to receive a reply of a keepalive@openssh.com global SSH request. The statistics are periodically sent via a new envelope type over the web socket where they are consumed and displayed to users. --- lib/defaults/defaults.go | 3 + lib/srv/regular/sshserver.go | 15 +- lib/utils/diagnostics/latency/monitor.go | 327 ++++++++++++++++++ lib/utils/diagnostics/latency/monitor_test.go | 141 ++++++++ lib/web/command_utils.go | 4 + lib/web/terminal.go | 87 ++++- web/packages/design/src/Icon/Icons.story.tsx | 1 + .../design/src/Icon/Icons/WarningCircle.tsx | 68 ++++ .../design/src/Icon/assets/WarningCircle.svg | 5 + web/packages/design/src/Icon/index.ts | 1 + .../LatencyDiagnostic.test.tsx | 107 ++++++ .../LatencyDiagnostic/LatencyDiagnostic.tsx | 246 +++++++++++++ .../components/LatencyDiagnostic/index.ts | 17 + .../src/Console/ActionBar/ActionBar.tsx | 12 + web/packages/teleport/src/Console/Console.tsx | 12 +- .../DocumentNodes/DocumentNodes.story.tsx | 4 + .../Console/DocumentSsh/DocumentSsh.story.tsx | 4 + .../src/Console/DocumentSsh/useSshSession.ts | 10 + .../teleport/src/Console/Tabs/Tabs.story.tsx | 12 + .../teleport/src/Console/consoleContext.tsx | 1 + .../teleport/src/Console/stores/types.ts | 6 + .../src/Console/useOnExitConfirmation.test.ts | 4 + web/packages/teleport/src/lib/term/enums.ts | 1 + .../teleport/src/lib/term/protobuf.js | 1 + web/packages/teleport/src/lib/term/tty.ts | 3 + 25 files changed, 1082 insertions(+), 10 deletions(-) create mode 100644 lib/utils/diagnostics/latency/monitor.go create mode 100644 lib/utils/diagnostics/latency/monitor_test.go create mode 100644 web/packages/design/src/Icon/Icons/WarningCircle.tsx create mode 100644 web/packages/design/src/Icon/assets/WarningCircle.svg create mode 100644 web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.test.tsx create mode 100644 web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.tsx create mode 100644 web/packages/shared/components/LatencyDiagnostic/index.ts diff --git a/lib/defaults/defaults.go b/lib/defaults/defaults.go index 497ce7db10aeb..186cef6d2e0bd 100644 --- a/lib/defaults/defaults.go +++ b/lib/defaults/defaults.go @@ -704,6 +704,9 @@ const ( // WebsocketError is sending an error message. WebsocketError = "e" + + // WebsocketLatency provides latency information for a session. + WebsocketLatency = "l" ) // The following are cryptographic primitives Teleport does not support in diff --git a/lib/srv/regular/sshserver.go b/lib/srv/regular/sshserver.go index c20a38aef0c8b..124203ebf6489 100644 --- a/lib/srv/regular/sshserver.go +++ b/lib/srv/regular/sshserver.go @@ -1928,15 +1928,14 @@ func (s *Server) handleEnvs(ch ssh.Channel, req *ssh.Request, ctx *srv.ServerCon // handleKeepAlive accepts and replies to keepalive@openssh.com requests. func (s *Server) handleKeepAlive(req *ssh.Request) { - s.Logger.Debugf("Received %q: WantReply: %v", req.Type, req.WantReply) - // only reply if the sender actually wants a response - if req.WantReply { - err := req.Reply(true, nil) - if err != nil { - s.Logger.Warnf("Unable to reply to %q request: %v", req.Type, err) - return - } + if !req.WantReply { + return + } + + if err := req.Reply(true, nil); err != nil { + s.Logger.Warnf("Unable to reply to %q request: %v", req.Type, err) + return } s.Logger.Debugf("Replied to %q", req.Type) diff --git a/lib/utils/diagnostics/latency/monitor.go b/lib/utils/diagnostics/latency/monitor.go new file mode 100644 index 0000000000000..ff7781b5ed446 --- /dev/null +++ b/lib/utils/diagnostics/latency/monitor.go @@ -0,0 +1,327 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package latency + +import ( + "context" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/sirupsen/logrus" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/utils/retryutils" +) + +var log = logrus.WithField(trace.Component, "latency") + +// Statistics contain latency measurements for both +// legs of a proxied connection. +type Statistics struct { + // Client measures the round trip time between the client and the Proxy. + Client int64 + // Server measures the round trip time the Proxy and the target host. + Server int64 +} + +// Reporter is an abstraction over how to provide the latency statistics to +// the consumer. Used by the Monitor to provide periodic latency updates. +type Reporter interface { + Report(ctx context.Context, statistics Statistics) error +} + +// ReporterFunc type is an adapter to allow the use of +// ordinary functions as a Reporter. If f is a function +// with the appropriate signature, Reporter(f) is a +// Reporter that calls f. +type ReporterFunc func(ctx context.Context, stats Statistics) error + +// Report calls f(ctx, stats). +func (f ReporterFunc) Report(ctx context.Context, stats Statistics) error { + return f(ctx, stats) +} + +// Pinger abstracts the mechanism used to measure the round trip time of +// a connection. All "ping" messages should be responded to before returning +// from [Pinger.Ping]. +type Pinger interface { + Ping(ctx context.Context) error +} + +// Monitor periodically pings both legs of a proxied connection and records +// the round trip times so that they may be emitted to consumers. +type Monitor struct { + clientPinger Pinger + serverPinger Pinger + reporter Reporter + clock clockwork.Clock + + pingInterval time.Duration + reportInterval time.Duration + + clientTimer clockwork.Timer + serverTimer clockwork.Timer + reportTimer clockwork.Timer + + clientLatency atomic.Int64 + serverLatency atomic.Int64 +} + +// MonitorConfig provides required dependencies for the [Monitor]. +type MonitorConfig struct { + // ClientPinger measure the round trip time for client half of the connection. + ClientPinger Pinger + // ServerPinger measure the round trip time for server half of the connection. + ServerPinger Pinger + // Reporter periodically emits statistics to consumers. + Reporter Reporter + // Clock used to measure time. + Clock clockwork.Clock + // InitialPingInterval an optional duration to use for the first PingInterval. + InitialPingInterval time.Duration + // PingInterval is the frequency at which both legs of the connection are pinged for + // latency calculations. + PingInterval time.Duration + // InitialReportInterval an optional duration to use for the first ReportInterval. + InitialReportInterval time.Duration + // ReportInterval is the frequency at which the latency information is reported. + ReportInterval time.Duration +} + +// CheckAndSetDefaults ensures required fields are provided and sets +// default values for any omitted optional fields. +func (c *MonitorConfig) CheckAndSetDefaults() error { + if c.ClientPinger == nil { + return trace.BadParameter("client pinger not provided to MonitorConfig") + } + + if c.ServerPinger == nil { + return trace.BadParameter("server pinger not provided to MonitorConfig") + } + + if c.Reporter == nil { + return trace.BadParameter("reporter not provided to MonitorConfig") + } + + if c.PingInterval <= 0 { + c.PingInterval = 3 * time.Second + } + + if c.InitialPingInterval <= 0 { + c.InitialReportInterval = fullJitter(500 * time.Millisecond) + } + + if c.ReportInterval <= 0 { + c.ReportInterval = 5 * time.Second + } + + if c.InitialReportInterval <= 0 { + c.InitialReportInterval = halfJitter(1500 * time.Millisecond) + } + + if c.Clock == nil { + c.Clock = clockwork.NewRealClock() + } + + return nil +} + +var ( + seventhJitter = retryutils.NewSeventhJitter() + fullJitter = retryutils.NewFullJitter() + halfJitter = retryutils.NewHalfJitter() +) + +// NewMonitor creates an unstarted [Monitor] with the provided configuration. To +// begin sampling connection latencies [Monitor.Run] must be called. +func NewMonitor(cfg MonitorConfig) (*Monitor, error) { + if err := cfg.CheckAndSetDefaults(); err != nil { + return nil, trace.Wrap(err) + } + + return &Monitor{ + clientPinger: cfg.ClientPinger, + serverPinger: cfg.ServerPinger, + clientTimer: cfg.Clock.NewTimer(cfg.InitialPingInterval), + serverTimer: cfg.Clock.NewTimer(cfg.InitialPingInterval), + reportTimer: cfg.Clock.NewTimer(cfg.InitialReportInterval), + reportInterval: cfg.ReportInterval, + pingInterval: cfg.PingInterval, + reporter: cfg.Reporter, + clock: cfg.Clock, + }, nil +} + +// GetStats returns a copy of the last known latency measurements. +func (m *Monitor) GetStats() Statistics { + return Statistics{ + Client: m.clientLatency.Load(), + Server: m.serverLatency.Load(), + } +} + +// Run periodically records round trip times. It should not be called +// more than once. +func (m *Monitor) Run(ctx context.Context) { + defer func() { + m.clientTimer.Stop() + m.serverTimer.Stop() + m.reportTimer.Stop() + }() + + go m.pingLoop(ctx, m.clientPinger, m.clientTimer, &m.clientLatency) + go m.pingLoop(ctx, m.serverPinger, m.serverTimer, &m.serverLatency) + + for { + select { + case <-m.reportTimer.Chan(): + if err := m.reporter.Report(ctx, m.GetStats()); err != nil { + log.WithError(err).Warn("failed to report latency stats") + } + m.reportTimer.Reset(seventhJitter(m.reportInterval)) + case <-ctx.Done(): + return + } + } +} + +func (m *Monitor) pingLoop(ctx context.Context, pinger Pinger, timer clockwork.Timer, latency *atomic.Int64) { + for { + select { + case <-ctx.Done(): + return + case <-timer.Chan(): + then := m.clock.Now() + if err := pinger.Ping(ctx); err != nil { + log.WithError(err).Warn("unexpected failure sending ping") + } else { + latency.Store(m.clock.Now().Sub(then).Milliseconds()) + } + timer.Reset(seventhJitter(m.pingInterval)) + } + } +} + +// SSHClient is the subset of the [ssh.Client] required by the [SSHPinger]. +type SSHClient interface { + SendRequest(ctx context.Context, name string, wantReply bool, payload []byte) (bool, []byte, error) +} + +// SSHPinger is a [Pinger] implementation that measures the latency of an +// SSH connection. To calculate round trip time, a keepalive@openssh.com request +// is sent. +type SSHPinger struct { + clt SSHClient + clock clockwork.Clock +} + +// NewSSHPinger creates a new [SSHPinger] with the provided configuration. +func NewSSHPinger(clock clockwork.Clock, clt SSHClient) (*SSHPinger, error) { + if clt == nil { + return nil, trace.BadParameter("ssh client not provided to SSHPinger") + } + + if clock == nil { + clock = clockwork.NewRealClock() + } + + return &SSHPinger{ + clt: clt, + clock: clock, + }, nil +} + +// Ping sends a keepalive@openssh.com request via the provided [SSHClient]. +func (s *SSHPinger) Ping(ctx context.Context) error { + _, _, err := s.clt.SendRequest(ctx, teleport.KeepAliveReqType, true, nil) + return trace.Wrap(err, "sending request %s", teleport.KeepAliveReqType) +} + +// WebSocket is the subset of [websocket.Conn] required by the [WebSocketPinger]. +type WebSocket interface { + WriteControl(messageType int, data []byte, deadline time.Time) error + PongHandler() func(appData string) error + SetPongHandler(h func(appData string) error) +} + +// WebSocketPinger is a [Pinger] implementation that measures the latency of a +// websocket connection. +type WebSocketPinger struct { + ws WebSocket + pongC chan string + clock clockwork.Clock +} + +// NewWebsocketPinger creates a [WebSocketPinger] with the provided configuration. +func NewWebsocketPinger(clock clockwork.Clock, ws WebSocket) (*WebSocketPinger, error) { + if ws == nil { + return nil, trace.BadParameter("web socket not provided to WebSocketPinger") + } + + if clock == nil { + clock = clockwork.NewRealClock() + } + + pinger := &WebSocketPinger{ + ws: ws, + clock: clock, + pongC: make(chan string, 1), + } + + handler := ws.PongHandler() + ws.SetPongHandler(func(payload string) error { + select { + case pinger.pongC <- payload: + default: + } + + if handler == nil { + return nil + } + + return trace.Wrap(handler(payload)) + }) + + return pinger, nil +} + +// Ping writes a ping control message and waits for the corresponding pong control message +// to be received before returning. The random identifier in the ping message is expected +// to be returned in the pong payload so that we can determine the true round trip time for +// a ping/pong message pair. +func (s *WebSocketPinger) Ping(ctx context.Context) error { + // websocketPingMessage denotes a ping control message. + const websocketPingMessage = 9 + + payload := uuid.NewString() + deadline := s.clock.Now().Add(2 * time.Second) + if err := s.ws.WriteControl(websocketPingMessage, []byte(payload), deadline); err != nil { + return trace.Wrap(err, "sending ping message") + } + + for { + select { + case pong := <-s.pongC: + if pong == payload { + return nil + } + case <-ctx.Done(): + return trace.Wrap(ctx.Err()) + } + } +} diff --git a/lib/utils/diagnostics/latency/monitor_test.go b/lib/utils/diagnostics/latency/monitor_test.go new file mode 100644 index 0000000000000..a14c29998d2dd --- /dev/null +++ b/lib/utils/diagnostics/latency/monitor_test.go @@ -0,0 +1,141 @@ +// Copyright 2023 Gravitational, Inc +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package latency + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/lib/utils" +) + +func TestMain(m *testing.M) { + utils.InitLoggerForTests() + + os.Exit(m.Run()) +} + +type fakePinger struct { + clock clockwork.FakeClock + latency time.Duration + pingC chan struct{} +} + +func (f fakePinger) Ping(ctx context.Context) error { + f.clock.Sleep(f.latency) + select { + case f.pingC <- struct{}{}: + default: + } + return nil +} + +type fakeReporter struct { + statsC chan Statistics +} + +func (f fakeReporter) Report(ctx context.Context, stats Statistics) error { + select { + case f.statsC <- stats: + default: + } + + return nil +} + +func TestMonitor(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + clock := clockwork.NewFakeClock() + + reporter := fakeReporter{ + statsC: make(chan Statistics, 20), + } + + const pingLatency = 2 * time.Second + clientPinger := fakePinger{clock: clock, latency: 2 * pingLatency, pingC: make(chan struct{}, 1)} + serverPinger := fakePinger{clock: clock, latency: pingLatency, pingC: make(chan struct{}, 1)} + + monitor, err := NewMonitor(MonitorConfig{ + ClientPinger: clientPinger, + ServerPinger: serverPinger, + Reporter: reporter, + Clock: clock, + PingInterval: 20 * time.Second, + InitialPingInterval: 20 * time.Second, + ReportInterval: 30 * time.Second, + InitialReportInterval: 30 * time.Second, + }) + require.NoError(t, err, "creating monitor") + + // Start the monitor in a goroutine since it's a blocking loop. The context + // is terminated when the test ends which will terminate the monitor. + go func() { + monitor.Run(ctx) + }() + + // Validate that stats are initially 0 for both legs. + stats := monitor.GetStats() + assert.Equal(t, Statistics{}, monitor.GetStats(), "expected initial latency stats to be zero got %v", stats) + + // Simulate a few ping loops to validate the pingers are activated appropriately. + for i := 0; i < 10; i++ { + // Wait for the ping timers and reporting timer to block. + clock.BlockUntil(3) + // Advance the clock enough to trigger a ping. + clock.Advance(monitor.pingInterval) + + pingTimeout := time.After(15 * time.Second) + // Wait for both pings to return a response. + for i := 0; i < 2; i++ { + // Wait for the fake pingers to sleep and the reporting timer to block. + clock.BlockUntil(3) + // Advance the clock in intervals of 5s to wake up the pingers one at a time. + // This works because one fake pinger is configured to have double the latency of + // the other. + clock.Advance(pingLatency) + + select { + case <-clientPinger.pingC: + case <-serverPinger.pingC: + case <-pingTimeout: + t.Fatal("ping never processed") + } + } + + // Wait for the ping timers and reporting timer to block. + clock.BlockUntil(3) + // Advance the clock enough to trigger a report. + clock.Advance(monitor.reportInterval - monitor.pingInterval - (pingLatency * 2)) + + // Validate the stats reported + reportTimeout := time.After(15 * time.Second) + select { + case reported := <-reporter.statsC: + current := monitor.GetStats() + assert.NotEqual(t, stats, reported, "expected reported stats not to be empty") + assert.NotEqual(t, stats, current, "expected retrieved stats not to be empty") + assert.Equal(t, reported, current, "expected reported and retrieved stats to match") + case <-reportTimeout: + t.Fatal("latency stats never received") + } + } +} diff --git a/lib/web/command_utils.go b/lib/web/command_utils.go index 0ef66311b521b..e9664c1a2f1f2 100644 --- a/lib/web/command_utils.go +++ b/lib/web/command_utils.go @@ -42,7 +42,11 @@ type WSConn interface { ReadMessage() (messageType int, p []byte, err error) SetReadLimit(limit int64) SetReadDeadline(t time.Time) error + + PongHandler() func(appData string) error SetPongHandler(h func(appData string) error) + CloseHandler() func(code int, text string) error + SetCloseHandler(h func(code int, text string) error) } const ( diff --git a/lib/web/terminal.go b/lib/web/terminal.go index 7bd98319f9fff..f58289a23bad0 100644 --- a/lib/web/terminal.go +++ b/lib/web/terminal.go @@ -61,6 +61,7 @@ import ( "github.com/gravitational/teleport/lib/session" "github.com/gravitational/teleport/lib/teleagent" "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/teleport/lib/utils/diagnostics/latency" ) // TerminalRequest describes a request to create a web-based terminal @@ -223,6 +224,10 @@ func (t *TerminalHandlerConfig) CheckAndSetDefaults() error { t.TracerProvider = tracing.DefaultProvider() } + if t.Clock == nil { + t.Clock = clockwork.NewRealClock() + } + t.tracer = t.TracerProvider.Tracer("webterminal") return nil @@ -289,6 +294,9 @@ type TerminalHandler struct { // closedByClient indicates if the websocket connection was closed by the // user (closing the browser tab, exiting the session, etc). closedByClient atomic.Bool + + // clock used to interact with time. + clock clockwork.Clock } // ServeHTTP builds a connection to the remote node and then pumps back two types of @@ -430,6 +438,17 @@ func (t *TerminalHandler) handler(ws *websocket.Conn, r *http.Request) { t.log.Debug("Closing websocket stream") } +// SSHSessionLatencyStats contain latency measurements for both +// legs of an ssh connection established via the Web UI. +type SSHSessionLatencyStats struct { + // WebSocket measures the round trip time for a ping/pong via the websocket + // established between the client and the Proxy. + WebSocket int64 `json:"ws"` + // SSH measures the round trip time for a keepalive@openssh.com request via the + // connection established between the Proxy and the target host. + SSH int64 `json:"ssh"` +} + type stderrWriter struct { stream *TerminalStream } @@ -756,6 +775,36 @@ func (t *sshBaseHandler) connectToHost(ctx context.Context, ws WSConn, tc *clien } } +func monitorSessionLatency(ctx context.Context, clock clockwork.Clock, stream *WSStream, sshClient *tracessh.Client) error { + wsPinger, err := latency.NewWebsocketPinger(clock, stream.ws) + if err != nil { + return trace.Wrap(err, "creating websocket pinger") + } + + sshPinger, err := latency.NewSSHPinger(clock, sshClient) + if err != nil { + return trace.Wrap(err, "creating ssh pinger") + } + + monitor, err := latency.NewMonitor(latency.MonitorConfig{ + ClientPinger: wsPinger, + ServerPinger: sshPinger, + Reporter: latency.ReporterFunc(func(ctx context.Context, statistics latency.Statistics) error { + return trace.Wrap(stream.writeLatency(SSHSessionLatencyStats{ + WebSocket: statistics.Client, + SSH: statistics.Server, + })) + }), + Clock: clock, + }) + if err != nil { + return trace.Wrap(err, "creating latency monitor") + } + + monitor.Run(ctx) + return nil +} + // streamTerminal opens a SSH connection to the remote host and streams // events back to the web client. func (t *TerminalHandler) streamTerminal(ctx context.Context, tc *client.TeleportClient) { @@ -783,6 +832,14 @@ func (t *TerminalHandler) streamTerminal(ctx context.Context, tc *client.Telepor } } + monitorCtx, monitorCancel := context.WithCancel(ctx) + defer monitorCancel() + go func() { + if err := monitorSessionLatency(monitorCtx, t.clock, t.stream.WSStream, nc.Client); err != nil { + t.log.WithError(err).Warn("failure monitoring session latency") + } + }() + // Establish SSH connection to the server. This function will block until // either an error occurs or it completes successfully. if err = nc.RunInteractiveShell(ctx, t.participantMode, t.tracker, beforeStart); err != nil { @@ -982,7 +1039,7 @@ func NewWStream(ctx context.Context, ws WSConn, log logrus.FieldLogger, handlers // NewTerminalStream creates a stream that manages reading and writing // data over the provided [websocket.Conn] -func NewTerminalStream(ctx context.Context, ws *websocket.Conn, log logrus.FieldLogger) *TerminalStream { +func NewTerminalStream(ctx context.Context, ws WSConn, log logrus.FieldLogger) *TerminalStream { t := &TerminalStream{ sessionReadyC: make(chan struct{}), } @@ -1296,6 +1353,34 @@ func (t *WSStream) writeAuditEvent(event []byte) error { return trace.Wrap(t.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)) } +func (t *WSStream) writeLatency(latency SSHSessionLatencyStats) error { + data, err := json.Marshal(latency) + if err != nil { + return trace.Wrap(err) + } + + encodedPayload, err := t.encoder.String(string(data)) + if err != nil { + return trace.Wrap(err) + } + + envelope := &Envelope{ + Version: defaults.WebsocketVersion, + Type: defaults.WebsocketLatency, + Payload: encodedPayload, + } + + envelopeBytes, err := proto.Marshal(envelope) + if err != nil { + return trace.Wrap(err) + } + + // Send bytes over the websocket to the web client. + t.mu.Lock() + defer t.mu.Unlock() + return trace.Wrap(t.ws.WriteMessage(websocket.BinaryMessage, envelopeBytes)) +} + // Write wraps the data bytes in a raw envelope and sends. func (t *WSStream) Write(data []byte) (n int, err error) { // UTF-8 encode data and wrap it in a raw envelope. diff --git a/web/packages/design/src/Icon/Icons.story.tsx b/web/packages/design/src/Icon/Icons.story.tsx index 2a1d2d8f2a5ff..dde49ca0d75d3 100644 --- a/web/packages/design/src/Icon/Icons.story.tsx +++ b/web/packages/design/src/Icon/Icons.story.tsx @@ -199,6 +199,7 @@ export const Icons = () => ( + diff --git a/web/packages/design/src/Icon/Icons/WarningCircle.tsx b/web/packages/design/src/Icon/Icons/WarningCircle.tsx new file mode 100644 index 0000000000000..fbd90fda14cea --- /dev/null +++ b/web/packages/design/src/Icon/Icons/WarningCircle.tsx @@ -0,0 +1,68 @@ +/* +Copyright 2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ + +import React from 'react'; + +import { Icon, IconProps } from '../Icon'; + +/* + +THIS FILE IS GENERATED. DO NOT EDIT. + +*/ + +export function WarningCircle({ size = 24, color, ...otherProps }: IconProps) { + return ( + + + + + + ); +} diff --git a/web/packages/design/src/Icon/assets/WarningCircle.svg b/web/packages/design/src/Icon/assets/WarningCircle.svg new file mode 100644 index 0000000000000..00924530e54b8 --- /dev/null +++ b/web/packages/design/src/Icon/assets/WarningCircle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/packages/design/src/Icon/index.ts b/web/packages/design/src/Icon/index.ts index 7198934be41ed..cfed14d9e86b3 100644 --- a/web/packages/design/src/Icon/index.ts +++ b/web/packages/design/src/Icon/index.ts @@ -187,6 +187,7 @@ export { VolumeUp } from './Icons/VolumeUp'; export { VpnKey } from './Icons/VpnKey'; export { Wand } from './Icons/Wand'; export { Warning } from './Icons/Warning'; +export { WarningCircle } from './Icons/WarningCircle'; export { Wifi } from './Icons/Wifi'; export { Windows } from './Icons/Windows'; export { Wrench } from './Icons/Wrench'; diff --git a/web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.test.tsx b/web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.test.tsx new file mode 100644 index 0000000000000..0029c15af8314 --- /dev/null +++ b/web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.test.tsx @@ -0,0 +1,107 @@ +/** + * Copyright 2023 Gravitational, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + latencyColors, + ERROR_THRESHOLD, + LatencyColor, + WARN_THRESHOLD, +} from 'shared/components/LatencyDiagnostic/LatencyDiagnostic'; + +test('latency colors', () => { + // unknown + // green + green = green + expect(latencyColors(undefined)).toStrictEqual({ + client: LatencyColor.Unknown, + server: LatencyColor.Unknown, + total: LatencyColor.Unknown, + }); + + // green + green = green + expect( + latencyColors({ client: WARN_THRESHOLD - 1, server: WARN_THRESHOLD - 1 }) + ).toStrictEqual({ + client: LatencyColor.Ok, + server: LatencyColor.Ok, + total: LatencyColor.Ok, + }); + + // green + yellow = yellow + expect( + latencyColors({ client: WARN_THRESHOLD - 1, server: WARN_THRESHOLD }) + ).toStrictEqual({ + client: LatencyColor.Ok, + server: LatencyColor.Warn, + total: LatencyColor.Warn, + }); + expect( + latencyColors({ client: WARN_THRESHOLD, server: WARN_THRESHOLD - 1 }) + ).toStrictEqual({ + client: LatencyColor.Warn, + server: LatencyColor.Ok, + total: LatencyColor.Warn, + }); + + // green + red = red + expect( + latencyColors({ client: WARN_THRESHOLD - 1, server: ERROR_THRESHOLD }) + ).toStrictEqual({ + client: LatencyColor.Ok, + server: LatencyColor.Error, + total: LatencyColor.Error, + }); + expect( + latencyColors({ client: ERROR_THRESHOLD, server: WARN_THRESHOLD - 1 }) + ).toStrictEqual({ + client: LatencyColor.Error, + server: LatencyColor.Ok, + total: LatencyColor.Error, + }); + + // yellow + yellow = yellow + expect( + latencyColors({ client: WARN_THRESHOLD, server: WARN_THRESHOLD }) + ).toStrictEqual({ + client: LatencyColor.Warn, + server: LatencyColor.Warn, + total: LatencyColor.Warn, + }); + + // yellow + red = red + expect( + latencyColors({ client: WARN_THRESHOLD, server: ERROR_THRESHOLD }) + ).toStrictEqual({ + client: LatencyColor.Warn, + server: LatencyColor.Error, + total: LatencyColor.Error, + }); + expect( + latencyColors({ client: ERROR_THRESHOLD, server: WARN_THRESHOLD }) + ).toStrictEqual({ + client: LatencyColor.Error, + server: LatencyColor.Warn, + total: LatencyColor.Error, + }); + + // red + red = red + expect( + latencyColors({ client: ERROR_THRESHOLD, server: ERROR_THRESHOLD }) + ).toStrictEqual({ + client: LatencyColor.Error, + server: LatencyColor.Error, + total: LatencyColor.Error, + }); +}); diff --git a/web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.tsx b/web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.tsx new file mode 100644 index 0000000000000..6b76c9236d2dc --- /dev/null +++ b/web/packages/shared/components/LatencyDiagnostic/LatencyDiagnostic.tsx @@ -0,0 +1,246 @@ +/** + Copyright 2023 Gravitational, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import styled from 'styled-components'; +import React from 'react'; + +import * as Icons from 'design/Icon'; +import { Flex, Text } from 'design'; +import { TeleportGearIcon } from 'design/SVGIcon'; + +import { DocumentSsh } from 'teleport/Console/stores'; + +import { MenuIcon } from 'shared/components/MenuAction'; + +export const WARN_THRESHOLD = 150; +export const ERROR_THRESHOLD = 400; + +export enum LatencyColor { + Ok = 'dataVisualisation.tertiary.caribbean', + Warn = 'dataVisualisation.tertiary.abbey', + Error = 'dataVisualisation.tertiary.sunflower', + Unknown = 'text.muted', +} + +function colorForLatency(l: number): LatencyColor { + if (l >= ERROR_THRESHOLD) { + return LatencyColor.Error; + } + + if (l >= WARN_THRESHOLD) { + return LatencyColor.Warn; + } + + return LatencyColor.Ok; +} + +// latencyColors determines the color to use for each leg of the connection +// and the total measurement. +export function latencyColors(latency: { client: number; server: number }): { + client: LatencyColor; + server: LatencyColor; + total: LatencyColor; +} { + if (latency === undefined) { + return { + client: LatencyColor.Unknown, + server: LatencyColor.Unknown, + total: LatencyColor.Unknown, + }; + } + + const clientColor = colorForLatency(latency.client); + const serverColor = colorForLatency(latency.server); + + // any + red = red + if (latency.client >= ERROR_THRESHOLD || latency.server >= ERROR_THRESHOLD) { + return { + client: clientColor, + server: serverColor, + total: LatencyColor.Error, + }; + } + + // any + yellow = yellow + if (latency.client >= WARN_THRESHOLD || latency.server >= WARN_THRESHOLD) { + return { + client: clientColor, + server: serverColor, + total: LatencyColor.Warn, + }; + } + + // green + green = green + return { client: clientColor, server: serverColor, total: LatencyColor.Ok }; +} + +export function LatencyDiagnostic({ + latency, +}: { + latency: DocumentSsh['latency']; +}) { + const colors = latencyColors(latency); + + return ( + + + + + Network Connection + + + + } + text="You" + alignItems="flex-start" + /> + + } + text="Teleport" + alignItems="center" + /> + + } + text="Server" + alignItems="flex-end" + /> + + + + + {latency === undefined && ( + + Connecting + + )} + + {latency !== undefined && colors.total === LatencyColor.Error && ( + + )} + + {latency !== undefined && colors.total === LatencyColor.Warn && ( + + )} + + {latency !== undefined && colors.total === LatencyColor.Ok && ( + + )} + + {latency !== undefined && ( + + Total Latency: {latency.client + latency.server}ms + + )} + + + + + + ); +} + +const IconContainer: React.FC<{ + icon: JSX.Element; + text: string; + alignItems: 'flex-start' | 'center' | 'flex-end'; +}> = ({ icon, text, alignItems }) => ( + + {icon} + {text} + +); + +const Leg: React.FC<{ + color: LatencyColor; + latency: number | undefined; +}> = ({ color, latency }) => ( + + + {latency !== undefined && {latency}ms} + {latency === undefined && } + +); + +// Looks like `<----->` +const DoubleSidedArrow = () => { + return ( + + + + + + ); +}; + +const Container = styled.div` + background: ${props => props.theme.colors.levels.elevated}; + padding: ${props => props.theme.space[4]}px; + width: 370px; +`; + +const Line = styled.div` + color: ${props => props.theme.colors.text.muted}; + border: 0.5px dashed; + width: 100%; +`; + +const Placeholder = styled.div` + height: 24px; +`; diff --git a/web/packages/shared/components/LatencyDiagnostic/index.ts b/web/packages/shared/components/LatencyDiagnostic/index.ts new file mode 100644 index 0000000000000..36b33f959b217 --- /dev/null +++ b/web/packages/shared/components/LatencyDiagnostic/index.ts @@ -0,0 +1,17 @@ +/** + Copyright 2023 Gravitational, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +export * from './LatencyDiagnostic'; diff --git a/web/packages/teleport/src/Console/ActionBar/ActionBar.tsx b/web/packages/teleport/src/Console/ActionBar/ActionBar.tsx index c964eb9eba861..d387f82407e8e 100644 --- a/web/packages/teleport/src/Console/ActionBar/ActionBar.tsx +++ b/web/packages/teleport/src/Console/ActionBar/ActionBar.tsx @@ -15,16 +15,23 @@ limitations under the License. */ import React from 'react'; + import { NavLink } from 'react-router-dom'; import { MenuIcon, MenuItem, MenuItemIcon } from 'shared/components/MenuAction'; +import { LatencyDiagnostic } from 'shared/components/LatencyDiagnostic'; + import * as Icons from 'design/Icon'; import { Flex, ButtonPrimary } from 'design'; import cfg from 'teleport/config'; +import { DocumentSsh } from 'teleport/Console/stores'; export default function ActionBar(props: Props) { return ( + {props.latencyIndicator.isVisible && ( + + )} - + {$docs} {hasSshSessions && ( diff --git a/web/packages/teleport/src/Console/DocumentNodes/DocumentNodes.story.tsx b/web/packages/teleport/src/Console/DocumentNodes/DocumentNodes.story.tsx index 2a7c8404aef7d..22558b2432036 100644 --- a/web/packages/teleport/src/Console/DocumentNodes/DocumentNodes.story.tsx +++ b/web/packages/teleport/src/Console/DocumentNodes/DocumentNodes.story.tsx @@ -74,6 +74,10 @@ const doc = { created: new Date('2019-05-13T20:18:09Z'), kind: 'nodes', url: 'localhost', + latency: { + client: 0, + server: 0, + }, } as const; const clusters = [ diff --git a/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.story.tsx b/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.story.tsx index 7468ab016f6f1..222654a82159b 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.story.tsx +++ b/web/packages/teleport/src/Console/DocumentSsh/DocumentSsh.story.tsx @@ -84,6 +84,10 @@ const doc = { id: 3, url: 'fd', created: new Date(), + latency: { + client: 123, + server: 456, + }, } as const; const session: Session = { diff --git a/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts b/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts index 0eef078af26ad..a71bb14d4654b 100644 --- a/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts +++ b/web/packages/teleport/src/Console/DocumentSsh/useSshSession.ts @@ -69,6 +69,16 @@ export default function useSshSession(doc: DocumentSsh) { handleTtyConnect(ctx, data.session, doc.id); }); + tty.on(TermEvent.LATENCY, payload => { + const stats = JSON.parse(payload); + ctx.updateSshDocument(doc.id, { + latency: { + client: stats.ws, + server: stats.ssh, + }, + }); + }); + // assign tty reference so it can be passed down to xterm ttyRef.current = tty; setSession(session); diff --git a/web/packages/teleport/src/Console/Tabs/Tabs.story.tsx b/web/packages/teleport/src/Console/Tabs/Tabs.story.tsx index 2c20b0a7c36c0..8cb44fe86ace2 100644 --- a/web/packages/teleport/src/Console/Tabs/Tabs.story.tsx +++ b/web/packages/teleport/src/Console/Tabs/Tabs.story.tsx @@ -54,6 +54,10 @@ const items = [ kind: 'nodes', url: 'localhost', created: new Date('2019-05-13T20:18:09Z'), + latency: { + client: 0, + server: 0, + }, } as const, { id: 22, @@ -62,6 +66,10 @@ const items = [ kind: 'nodes', url: 'localhost', created: new Date('2019-05-13T20:18:09Z'), + latency: { + client: 0, + server: 0, + }, } as const, { id: 23, @@ -70,5 +78,9 @@ const items = [ kind: 'nodes', url: 'localhost', created: new Date('2019-05-13T20:18:09Z'), + latency: { + client: 0, + server: 0, + }, } as const, ]; diff --git a/web/packages/teleport/src/Console/consoleContext.tsx b/web/packages/teleport/src/Console/consoleContext.tsx index 519a63890b61d..0d5a937fc344b 100644 --- a/web/packages/teleport/src/Console/consoleContext.tsx +++ b/web/packages/teleport/src/Console/consoleContext.tsx @@ -117,6 +117,7 @@ export default class ConsoleContext { url, mode, created: new Date(), + latency: undefined, }); } diff --git a/web/packages/teleport/src/Console/stores/types.ts b/web/packages/teleport/src/Console/stores/types.ts index bcd597baca361..878389513830c 100644 --- a/web/packages/teleport/src/Console/stores/types.ts +++ b/web/packages/teleport/src/Console/stores/types.ts @@ -36,6 +36,12 @@ export interface DocumentSsh extends DocumentBase { mode?: ParticipantMode; serverId: string; login: string; + latency: + | { + client: number; + server: number; + } + | undefined; } export interface DocumentNodes extends DocumentBase { diff --git a/web/packages/teleport/src/Console/useOnExitConfirmation.test.ts b/web/packages/teleport/src/Console/useOnExitConfirmation.test.ts index a41020af1a5c9..73389b18c70f0 100644 --- a/web/packages/teleport/src/Console/useOnExitConfirmation.test.ts +++ b/web/packages/teleport/src/Console/useOnExitConfirmation.test.ts @@ -62,6 +62,10 @@ test('confirmation dialog before terminating an active ssh session', () => { login: 'login', created: new Date(), sid: 'random-123-sid', + latency: { + client: 0, + server: 0, + }, }); docs = ctx.getDocuments(); diff --git a/web/packages/teleport/src/lib/term/enums.ts b/web/packages/teleport/src/lib/term/enums.ts index dd422f995627f..cd17ea52a0ebd 100644 --- a/web/packages/teleport/src/lib/term/enums.ts +++ b/web/packages/teleport/src/lib/term/enums.ts @@ -35,6 +35,7 @@ export enum TermEvent { DATA = 'terminal.data', CONN_CLOSE = 'connection.close', WEBAUTHN_CHALLENGE = 'terminal.webauthn', + LATENCY = 'terminal.latency', } // Websocket connection close codes. diff --git a/web/packages/teleport/src/lib/term/protobuf.js b/web/packages/teleport/src/lib/term/protobuf.js index c654dacbef29c..c47cde49f7926 100644 --- a/web/packages/teleport/src/lib/term/protobuf.js +++ b/web/packages/teleport/src/lib/term/protobuf.js @@ -30,6 +30,7 @@ export const MessageTypeEnum = { FILE_TRANSFER_REQUEST: 'f', FILE_TRANSFER_DECISION: 't', WEBAUTHN_CHALLENGE: 'n', + LATENCY: 'l', }; export const messageFields = { diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index 852898ef020d9..0e4f264f0116f 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -199,6 +199,9 @@ class Tty extends EventEmitterWebAuthnSender { this.emit(TermEvent.DATA, msg.payload); } break; + case MessageTypeEnum.LATENCY: + this.emit(TermEvent.LATENCY, msg.payload); + break; default: throw Error(`unknown message type: ${msg.type}`); }