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