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..3a89182b47f0a 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
@@ -430,6 +435,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 +772,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 +829,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 +1036,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 +1350,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}`);
}