From 6dad93c7342b129a4feb466bc587bdfa1e5ee3d9 Mon Sep 17 00:00:00 2001 From: Zac Bergquist Date: Sat, 6 Jan 2024 11:24:13 -0700 Subject: [PATCH] Update the web UI to stream session playback (#36168) Prior to this, the web UI would download the entire session recording and store it in JavaScript memory before starting playback. This caused the browser tab to crash when attempting to play back sessions larger than ~5MB. For playback, we use a custom binary protocol rather than the protobuf envelopes that we use for live sessions. The protobuf envelopes only send raw PTY data, there is no place to put the timing data. Adding fields to the envelope would be a disruptive change because our JS codec is hand-rolled and we'd have to make the parsing updates manually. Updates gravitational/teleport-private#1024 Closes gravitational/teleport-private#665 Closes #10578 --- lib/auth/apiserver.go | 4 +- lib/player/player.go | 17 +- lib/player/player_test.go | 1 + lib/web/apiserver.go | 9 +- lib/web/tty_playback.go | 358 ++++++ lib/web/tty_playback_test.go | 37 + web/packages/teleport/package.json | 5 +- web/packages/teleport/src/Assist/service.ts | 2 + web/packages/teleport/src/Player/Player.tsx | 13 +- .../src/Player/ProgressBar/ProgressBar.tsx | 18 +- .../src/Player/ProgressBar/ProgressBarTty.tsx | 87 -- .../src/Player/ProgressBar/Slider/Slider.jsx | 18 - .../teleport/src/Player/ProgressBar/index.tsx | 3 +- .../teleport/src/Player/SshPlayer.tsx | 106 +- .../src/Recordings/RecordingsList.tsx | 2 +- .../Recordings.story.test.tsx.snap | 6 +- web/packages/teleport/src/config.ts | 7 +- .../src/lib/term/fixtures/streamData.js | 1130 ----------------- .../teleport/src/lib/term/terminal.ts | 6 +- web/packages/teleport/src/lib/term/tty.ts | 1 + .../src/lib/term/ttyAddressResolver.js | 6 +- .../teleport/src/lib/term/ttyPlayer.js | 382 +++--- .../teleport/src/lib/term/ttyPlayer.test.js | 316 ++--- .../src/lib/term/ttyPlayerEventProvider.js | 230 ---- yarn.lock | 21 +- 25 files changed, 817 insertions(+), 1968 deletions(-) create mode 100644 lib/web/tty_playback.go create mode 100644 lib/web/tty_playback_test.go delete mode 100644 web/packages/teleport/src/Player/ProgressBar/ProgressBarTty.tsx delete mode 100644 web/packages/teleport/src/lib/term/fixtures/streamData.js delete mode 100644 web/packages/teleport/src/lib/term/ttyPlayerEventProvider.js diff --git a/lib/auth/apiserver.go b/lib/auth/apiserver.go index 244fdea4e0cc7..9b86c86d82164 100644 --- a/lib/auth/apiserver.go +++ b/lib/auth/apiserver.go @@ -144,8 +144,8 @@ func NewAPIServer(config *APIConfig) (http.Handler, error) { srv.POST("/:version/tokens/register", srv.WithAuth(srv.registerUsingToken)) // Active sessions - srv.GET("/:version/namespaces/:namespace/sessions/:id/stream", srv.WithAuth(srv.getSessionChunk)) - srv.GET("/:version/namespaces/:namespace/sessions/:id/events", srv.WithAuth(srv.getSessionEvents)) + srv.GET("/:version/namespaces/:namespace/sessions/:id/stream", srv.WithAuth(srv.getSessionChunk)) // DELETE IN 16(zmb3) + srv.GET("/:version/namespaces/:namespace/sessions/:id/events", srv.WithAuth(srv.getSessionEvents)) // DELETE IN 16(zmb3) // Namespaces srv.POST("/:version/namespaces", srv.WithAuth(srv.upsertNamespace)) diff --git a/lib/player/player.go b/lib/player/player.go index 0a579685223de..9af0517f0dd29 100644 --- a/lib/player/player.go +++ b/lib/player/player.go @@ -116,7 +116,7 @@ func New(cfg *Config) (*Player, error) { log: log, sessionID: cfg.SessionID, streamer: cfg.Streamer, - emit: make(chan events.AuditEvent, 64), + emit: make(chan events.AuditEvent, 1024), playPause: make(chan chan struct{}, 1), done: make(chan struct{}), } @@ -185,7 +185,7 @@ func (p *Player) stream() { } currentDelay := getDelay(evt) - if currentDelay > 0 && currentDelay > lastDelay { + if currentDelay > 0 && currentDelay >= lastDelay { switch adv := p.advanceTo.Load(); { case adv >= currentDelay: // no timing delay necessary, we are fast forwarding @@ -215,12 +215,13 @@ func (p *Player) stream() { lastDelay = currentDelay } - select { - case p.emit <- evt: - p.lastPlayed.Store(currentDelay) - default: - p.log.Warnf("dropped event %v, reader too slow", evt.GetID()) - } + // if the receiver can't keep up, let the channel throttle us + // (it's better for playback to be a little slower than realtime + // than to drop events) + // + // TODO: consider a select with a timeout to detect blocked readers? + p.emit <- evt + p.lastPlayed.Store(currentDelay) } } } diff --git a/lib/player/player_test.go b/lib/player/player_test.go index 6727fd5b543a6..44414ad83ebce 100644 --- a/lib/player/player_test.go +++ b/lib/player/player_test.go @@ -169,6 +169,7 @@ func TestClose(t *testing.T) { _, ok := <-p.C() require.False(t, ok, "player channel should have been closed") require.NoError(t, p.Err()) + require.Equal(t, int64(1000), p.LastPlayed()) } func TestSeekForward(t *testing.T) { diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index b721fe966f32c..250ce93590924 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -717,8 +717,11 @@ func (h *Handler) bindDefaultEndpoints() { // Audit events handlers. h.GET("/webapi/sites/:site/events/search", h.WithClusterAuth(h.clusterSearchEvents)) // search site events h.GET("/webapi/sites/:site/events/search/sessions", h.WithClusterAuth(h.clusterSearchSessionEvents)) // search site session events - h.GET("/webapi/sites/:site/sessions/:sid/events", h.WithClusterAuth(h.siteSessionEventsGet)) // get recorded session's timing information (from events) - h.GET("/webapi/sites/:site/sessions/:sid/stream", h.siteSessionStreamGet) // get recorded session's bytes (from events) + h.GET("/webapi/sites/:site/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle)) + + // DELETE in 16(zmb3): v15+ web UIs use new streaming 'ttyplayback' endpoint + h.GET("/webapi/sites/:site/sessions/:sid/events", h.WithClusterAuth(h.siteSessionEventsGet)) // get recorded session's timing information (from events) + h.GET("/webapi/sites/:site/sessions/:sid/stream", h.siteSessionStreamGet) // get recorded session's bytes (from events) // scp file transfer h.GET("/webapi/sites/:site/nodes/:server/:login/scp", h.WithClusterAuth(h.transferFile)) @@ -3417,6 +3420,8 @@ func queryOrder(query url.Values, name string, def types.EventOrder) (types.Even // It returns the binary stream unencoded, directly in the respose body, // with Content-Type of application/octet-stream, gzipped with up to 95% // compression ratio. +// +// DELETE IN 16(zmb3) func (h *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p httprouter.Params) { httplib.SetNoCacheHeaders(w.Header()) diff --git a/lib/web/tty_playback.go b/lib/web/tty_playback.go new file mode 100644 index 0000000000000..3f04a14e0bf00 --- /dev/null +++ b/lib/web/tty_playback.go @@ -0,0 +1,358 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "bytes" + "context" + "encoding/binary" + "net/http" + "time" + + "github.com/gorilla/websocket" + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + + "github.com/gravitational/teleport/api/types/events" + "github.com/gravitational/teleport/lib/player" + "github.com/gravitational/teleport/lib/reversetunnelclient" + "github.com/gravitational/teleport/lib/session" + "github.com/gravitational/teleport/lib/utils" +) + +const ( + messageTypePTY = byte(1) + messageTypeError = byte(2) + messageTypePlayPause = byte(3) + messageTypeSeek = byte(4) + messageTypeResize = byte(5) +) + +const ( + severityError = byte(1) +) + +const ( + actionPlay = byte(0) + actionPause = byte(1) +) + +func (h *Handler) ttyPlaybackHandle( + w http.ResponseWriter, + r *http.Request, + p httprouter.Params, + sctx *SessionContext, + site reversetunnelclient.RemoteSite, +) (interface{}, error) { + sID := p.ByName("sid") + if sID == "" { + return nil, trace.BadParameter("missing session ID in request URL") + } + + clt, err := sctx.GetUserClient(r.Context(), site) + if err != nil { + return nil, trace.Wrap(err) + } + + h.log.Debug("upgrading to websocket") + upgrader := websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + } + ws, err := upgrader.Upgrade(w, r, nil) + if err != nil { + h.log.Warn("failed upgrade", err) + // if Upgrade fails, it automatically replies with an HTTP error + // (this means we don't need to return an error here) + return nil, nil + } + + player, err := player.New(&player.Config{ + Clock: h.clock, + Log: h.log, + SessionID: session.ID(sID), + Streamer: clt, + }) + if err != nil { + h.log.Warn("player error", err) + writeError(ws, err) + return nil, nil + } + + ctx, cancel := context.WithCancel(r.Context()) + defer cancel() + + go func() { + defer cancel() + for { + typ, b, err := ws.ReadMessage() + if err != nil { + if !utils.IsOKNetworkError(err) { + log.Warnf("websocket read error: %v", err) + } + return + } + + if typ != websocket.BinaryMessage { + log.Debugf("skipping unknown websocket message type %v", typ) + continue + } + + if err := handlePlaybackAction(b, player); err != nil { + log.Warnf("skipping bad action: %v", err) + continue + } + } + }() + + go func() { + defer cancel() + defer func() { + h.log.Debug("closing websocket") + if err := ws.WriteMessage(websocket.CloseMessage, nil); err != nil { + h.log.Debugf("error sending close message: %v", err) + } + if err := ws.Close(); err != nil { + h.log.Debugf("error closing websocket: %v", err) + } + }() + + player.Play() + defer player.Close() + + headerBuf := make([]byte, 11) + headerBuf[0] = messageTypePTY + + writePTY := func(b []byte, delay uint64) error { + writer, err := ws.NextWriter(websocket.BinaryMessage) + if err != nil { + return trace.Wrap(err, "getting websocket writer") + } + msgLen := uint16(len(b) + 8) + binary.BigEndian.PutUint16(headerBuf[1:], msgLen) + binary.BigEndian.PutUint64(headerBuf[3:], delay) + if _, err := writer.Write(headerBuf); err != nil { + return trace.Wrap(err, "writing message header") + } + + // TODO(zmb3): consider optimizing this by bufering for very large sessions + // (wait up to N ms to batch events into a single websocket write). + if _, err := writer.Write(b); err != nil { + return trace.Wrap(err, "writing PTY data") + } + + if err := writer.Close(); err != nil { + return trace.Wrap(err, "closing websocket writer") + } + + return nil + } + + writeSize := func(size string) error { + ts, err := session.UnmarshalTerminalParams(size) + if err != nil { + h.log.Debugf("Ignoring invalid terminal size %q", size) + return nil // don't abort playback due to a bad event + } + + msg := make([]byte, 7) + msg[0] = messageTypeResize + binary.BigEndian.PutUint16(msg[1:], 4) + binary.BigEndian.PutUint16(msg[3:], uint16(ts.W)) + binary.BigEndian.PutUint16(msg[5:], uint16(ts.H)) + + return trace.Wrap(ws.WriteMessage(websocket.BinaryMessage, msg)) + } + + for { + select { + case <-ctx.Done(): + return + case evt, ok := <-player.C(): + if !ok { + // send any playback errors to the browser + if err := writeError(ws, player.Err()); err != nil { + h.log.Warnf("failed to send error message to browser: %v", err) + } + return + } + + switch evt := evt.(type) { + case *events.SessionStart: + if err := writeSize(evt.TerminalSize); err != nil { + h.log.Debugf("Failed to write resize: %v", err) + return + } + + case *events.SessionPrint: + if err := writePTY(evt.Data, uint64(evt.DelayMilliseconds)); err != nil { + h.log.Debugf("Failed to send PTY data: %v", err) + return + } + + case *events.SessionEnd: + // send empty PTY data - this will ensure that any dead time + // at the end of the recording is processed and the allow + // the progress bar to go to 100% + if err := writePTY(nil, uint64(evt.EndTime.Sub(evt.StartTime)/time.Millisecond)); err != nil { + h.log.Debugf("Failed to send session end data: %v", err) + return + } + + case *events.Resize: + if err := writeSize(evt.TerminalSize); err != nil { + h.log.Debugf("Failed to write resize: %v", err) + return + } + + default: + h.log.Debugf("unexpected event type %T", evt) + } + } + } + }() + + <-ctx.Done() + return nil, nil +} + +func writeError(ws *websocket.Conn, err error) error { + if err == nil { + return nil + } + + b := new(bytes.Buffer) + b.WriteByte(messageTypeError) + + msg := trace.UserMessage(err) + l := 1 /* severity */ + 2 /* msg length */ + len(msg) + binary.Write(b, binary.BigEndian, uint16(l)) + b.WriteByte(severityError) + binary.Write(b, binary.BigEndian, uint16(len(msg))) + b.WriteString(msg) + + return trace.Wrap(ws.WriteMessage(websocket.BinaryMessage, b.Bytes())) +} + +type play interface { + Play() error + Pause() error + SetPos(time.Duration) error +} + +// handlePlaybackAction processes a playback message +// received from the browser +func handlePlaybackAction(b []byte, p play) error { + if len(b) < 3 { + return trace.BadParameter("invalid playback message") + } + + msgType := b[0] + msgLen := binary.BigEndian.Uint16(b[1:]) + + if len(b) < int(msgLen+3) { + return trace.BadParameter("invalid message length") + } + + payload := b[3:] + payload = payload[:msgLen] + + switch msgType { + case messageTypePlayPause: + if len(payload) != 1 { + return trace.BadParameter("invalid play/pause command") + } + switch action := payload[0]; action { + case actionPlay: + p.Play() + case actionPause: + p.Pause() + default: + return trace.BadParameter("invalid play/pause action %v", action) + } + case messageTypeSeek: + if len(payload) != 8 { + return trace.BadParameter("invalid seek message") + } + pos := binary.BigEndian.Uint64(payload) + p.SetPos(time.Duration(pos) * time.Millisecond) + } + + return nil +} + +/* + +# Websocket Protocol for TTY Playback: + +During playback, the Teleport proxy sends session data to the browser +and the browser sends playback commands (play/pause, seek, etc) to the +proxy. + +Each message conforms to the following binary protocol. + +## Message Header + +The message header starts with a 1-byte identifier followed by a 2-byte +(big endian) integer containing the number of bytes following the header. +This length field does not include the 3-byte header. + +## Messages + +### 1 - PTY data + +This message is used to send recorded PTY data to the browser. + +- Message ID: 1 +- 8-byte timestamp (milliseconds since session start) +- PTY data + +### 2 - Error + +This message is used to indicate that an error has occurred. + +- Message ID: 2 +- 1 byte severity (1=error) +- 2-byte error message length +- variable length error message (UTF-8 text) + +### 3 - Play/Pause + +This message is sent from the browser to the server to pause +or resume playback. + +- Message ID: 3 +- 1-byte code (0=play, 1=pause) + +### 4 - Seek + +This message is used to seek to a new position in the recording. + +- Message ID: 4 +- 8-byte timestamp (milliseconds since session start) + +### 5 - Resize + +This message is used to indicate that the termina was resized. + +- Message ID: 5 +- 2-byte width +- 2-byte height + +*/ diff --git a/lib/web/tty_playback_test.go b/lib/web/tty_playback_test.go new file mode 100644 index 0000000000000..bbd575d979fd3 --- /dev/null +++ b/lib/web/tty_playback_test.go @@ -0,0 +1,37 @@ +/* + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "testing" + "time" +) + +func FuzzHandlePlaybackAction(f *testing.F) { + player := nopPlayer{} + f.Fuzz(func(t *testing.T, b []byte) { + handlePlaybackAction(b, player) + }) +} + +type nopPlayer struct{} + +func (nopPlayer) SetPos(time.Duration) error { return nil } +func (nopPlayer) Play() error { return nil } +func (nopPlayer) Pause() error { return nil } diff --git a/web/packages/teleport/package.json b/web/packages/teleport/package.json index bd47f3c03799c..e2588b5870a52 100644 --- a/web/packages/teleport/package.json +++ b/web/packages/teleport/package.json @@ -25,11 +25,12 @@ "xterm-addon-web-links": "^0.8.0" }, "devDependencies": { - "babel-plugin-transform-import-meta": "^2.2.0", - "babel-plugin-transform-vite-meta-env": "^1.0.3", "@gravitational/build": "^1.0.0", "@types/wicg-file-system-access": "^2020.9.5", + "babel-plugin-transform-import-meta": "^2.2.0", + "babel-plugin-transform-vite-meta-env": "^1.0.3", "jest-canvas-mock": "^2.3.1", + "jest-websocket-mock": "^2.5.0", "ts-loader": "^9.4.2" } } diff --git a/web/packages/teleport/src/Assist/service.ts b/web/packages/teleport/src/Assist/service.ts index 6f0a6f4ff7568..9897373e0d8fb 100644 --- a/web/packages/teleport/src/Assist/service.ts +++ b/web/packages/teleport/src/Assist/service.ts @@ -101,6 +101,8 @@ export async function resolveServerMessage( } } +// TODO(zmb3): check with Ryan about replacing this with streaming + export async function getSessionEvents(sessionUrl: string): Promise<{ events: SessionEvent[] | null; }> { diff --git a/web/packages/teleport/src/Player/Player.tsx b/web/packages/teleport/src/Player/Player.tsx index 2969f3de7b115..189f673c186a9 100644 --- a/web/packages/teleport/src/Player/Player.tsx +++ b/web/packages/teleport/src/Player/Player.tsx @@ -36,6 +36,8 @@ import { DesktopPlayer } from './DesktopPlayer'; import SshPlayer from './SshPlayer'; import Tabs, { TabItem } from './PlayerTabs'; +const validRecordingTypes = ['ssh', 'k8s', 'desktop']; + export default function Player() { const { sid, clusterId } = useParams(); const { search } = useLocation(); @@ -46,10 +48,7 @@ export default function Player() { ) as RecordingType; const durationMs = Number(getUrlParameter('durationMs', search)); - const validRecordingType = - recordingType === 'ssh' || - recordingType === 'k8s' || - recordingType === 'desktop'; + const validRecordingType = validRecordingTypes.includes(recordingType); const validDurationMs = Number.isInteger(durationMs) && durationMs > 0; document.title = `${clusterId} • Play ${sid}`; @@ -64,14 +63,14 @@ export default function Player() { Invalid query parameter recordingType: {recordingType}, should be - 'ssh' or 'desktop' + one of {validRecordingTypes.join(', ')}. ); } - if (recordingType === 'desktop' && !validDurationMs) { + if (!validDurationMs) { return ( @@ -106,7 +105,7 @@ export default function Player() { durationMs={durationMs} /> ) : ( - + )} diff --git a/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx b/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx index 403be4bcb9a65..81aa8be12b6d2 100644 --- a/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx +++ b/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx @@ -26,7 +26,7 @@ export default function ProgressBar(props: ProgressBarProps) { const Icon = props.isPlaying ? Icons.CirclePause : Icons.CirclePlay; return ( - + @@ -36,7 +36,9 @@ export default function ProgressBar(props: ProgressBarProps) { min={props.min} max={props.max} value={props.current} - onChange={props.move} + disabled={props.disabled} + onBeforeChange={props.onStartMove} + onAfterChange={props.move} defaultValue={1} withBars className="grv-slider" @@ -67,13 +69,15 @@ function Restart(props: { onRestart?: () => void }) { export type ProgressBarProps = { max: number; min: number; - time: any; + time: string; isPlaying: boolean; + disabled?: boolean; current: number; move: (value: any) => void; toggle: () => void; style?: React.CSSProperties; id?: string; + onStartMove?: () => void; onPlaySpeedChange?: (newSpeed: number) => void; onRestart?: () => void; }; @@ -140,7 +144,13 @@ const ActionButton = styled.button` color: ${props => props.theme.colors.text.main}; } - &:hover { + &:disabled { + .icon { + color: ${props => props.theme.colors.text.disabled}; + } + } + + &:hover:enabled { opacity: 1; .icon { diff --git a/web/packages/teleport/src/Player/ProgressBar/ProgressBarTty.tsx b/web/packages/teleport/src/Player/ProgressBar/ProgressBarTty.tsx deleted file mode 100644 index e1b387c964f35..0000000000000 --- a/web/packages/teleport/src/Player/ProgressBar/ProgressBarTty.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React from 'react'; -import { throttle } from 'shared/utils/highbar'; - -import TtyPlayer from 'teleport/lib/term/ttyPlayer'; - -import ProgressBar from './ProgressBar'; - -export default function ProgressBarTty(props: { tty: TtyPlayer }) { - const state = useTtyProgress(props.tty); - return ; -} - -export function useTtyProgress(tty: TtyPlayer) { - const [state, setState] = React.useState(() => { - return makeTtyProgress(tty); - }); - - React.useEffect(() => { - const throttledOnChange = throttle( - onChange, - // some magic numbers to reduce number of re-renders when - // session is too long and "eventful" - Math.max(Math.min(tty.duration * 0.025, 500), 20) - ); - - function onChange() { - // recalculate progress state - const ttyProgres = makeTtyProgress(tty); - setState(ttyProgres); - } - - function cleanup() { - throttledOnChange.cancel(); - tty.stop(); - tty.removeAllListeners(); - } - - tty.on('change', throttledOnChange); - - return cleanup; - }, [tty]); - - return state; -} - -function makeTtyProgress(tty: TtyPlayer) { - function toggle() { - if (tty.isPlaying()) { - tty.stop(); - } else { - tty.play(); - } - } - - function move(value) { - tty.move(value); - } - - return { - max: tty.duration, - min: 1, - time: tty.getCurrentTime(), - isLoading: tty.isLoading(), - isPlaying: tty.isPlaying(), - current: tty.current, - move, - toggle, - }; -} diff --git a/web/packages/teleport/src/Player/ProgressBar/Slider/Slider.jsx b/web/packages/teleport/src/Player/ProgressBar/Slider/Slider.jsx index 3e57a55645dfc..9ad8f1b901224 100644 --- a/web/packages/teleport/src/Player/ProgressBar/Slider/Slider.jsx +++ b/web/packages/teleport/src/Player/ProgressBar/Slider/Slider.jsx @@ -1,21 +1,3 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - /* The MIT License (MIT) diff --git a/web/packages/teleport/src/Player/ProgressBar/index.tsx b/web/packages/teleport/src/Player/ProgressBar/index.tsx index 9bfd7aa65de68..9d892ade1a00f 100644 --- a/web/packages/teleport/src/Player/ProgressBar/index.tsx +++ b/web/packages/teleport/src/Player/ProgressBar/index.tsx @@ -17,8 +17,7 @@ */ import ProgressBar from './ProgressBar'; -import ProgressBarTty from './ProgressBarTty'; import { ProgressBarDesktop } from './ProgressBarDesktop'; export default ProgressBar; -export { ProgressBarTty, ProgressBarDesktop }; +export { ProgressBarDesktop }; diff --git a/web/packages/teleport/src/Player/SshPlayer.tsx b/web/packages/teleport/src/Player/SshPlayer.tsx index 544230f2644e3..479f9237812ed 100644 --- a/web/packages/teleport/src/Player/SshPlayer.tsx +++ b/web/packages/teleport/src/Player/SshPlayer.tsx @@ -18,24 +18,27 @@ import React from 'react'; import styled from 'styled-components'; +import { Indicator, Flex, Box } from 'design'; import { Danger } from 'design/Alert'; -import { Indicator, Flex, Text, Box } from 'design'; import cfg from 'teleport/config'; import TtyPlayer, { + StatusEnum, StatusEnum as TtyStatusEnum, } from 'teleport/lib/term/ttyPlayer'; -import EventProvider from 'teleport/lib/term/ttyPlayerEventProvider'; +import { getAccessToken, getHostName } from 'teleport/services/api'; -import { ProgressBarTty } from './ProgressBar'; +import ProgressBar from './ProgressBar'; import Xterm from './Xterm'; -export default function Player({ sid, clusterId }) { - const { tty } = useSshPlayer(clusterId, sid); - const { statusText, status } = tty; - const eventCount = tty.getEventCount(); - const isError = status === TtyStatusEnum.ERROR; - const isLoading = status === TtyStatusEnum.LOADING; +export default function Player({ sid, clusterId, durationMs }) { + const { tty, playerStatus, statusText, time } = useStreamingSshPlayer( + clusterId, + sid + ); + const isError = playerStatus === TtyStatusEnum.ERROR; + const isLoading = playerStatus === TtyStatusEnum.LOADING; + const isPlaying = playerStatus === TtyStatusEnum.PLAYING; if (isError) { return ( @@ -53,22 +56,31 @@ export default function Player({ sid, clusterId }) { ); } - if (!isLoading && eventCount === 0) { - return ( - - - Recording for this session is not available. - - - ); - } - return ( - {eventCount > 0 && } + { + tty.move(pos); + tty.resumeTimeUpdates(); + }} + toggle={() => { + isPlaying ? tty.stop() : tty.play(); + }} + /> ); } @@ -87,35 +99,45 @@ const StyledPlayer = styled.div` justify-content: space-between; `; -function useSshPlayer(clusterId: string, sid: string) { - const tty = React.useMemo(() => { - const prefixUrl = cfg.getSshPlaybackPrefixUrl({ clusterId, sid }); - return new TtyPlayer(new EventProvider({ url: prefixUrl })); - }, [sid, clusterId]); +function useStreamingSshPlayer(clusterId: string, sid: string) { + const [playerStatus, setPlayerStatus] = React.useState(StatusEnum.LOADING); + const [statusText, setStatusText] = React.useState(''); + const [time, setTime] = React.useState(0); - // to trigger re-render when tty state changes - const [, rerender] = React.useState(tty.status); + const tty = React.useMemo(() => { + const url = cfg.api.ttyPlaybackWsAddr + .replace(':fqdn', getHostName()) + .replace(':clusterId', clusterId) + .replace(':sid', sid) + .replace(':token', getAccessToken()); + return new TtyPlayer({ url, setPlayerStatus, setStatusText, setTime }); + }, [clusterId, sid, setPlayerStatus, setStatusText, setTime]); React.useEffect(() => { - function onChange() { - // trigger rerender when status changes - rerender(tty.status); - } + tty.connect(); + tty.play(); - function cleanup() { + return () => { tty.stop(); tty.removeAllListeners(); - } + }; + }, [tty]); - tty.on('change', onChange); - tty.connect().then(() => { - tty.play(); - }); + return { tty, playerStatus, statusText, time }; +} - return cleanup; - }, [tty]); +function formatDisplayTime(ms: number) { + if (ms <= 0) { + return '00:00'; + } + + const totalSec = Math.floor(ms / 1000); + const totalDays = (totalSec % 31536000) % 86400; + const h = Math.floor(totalDays / 3600); + const m = Math.floor((totalDays % 3600) / 60); + const s = (totalDays % 3600) % 60; - return { - tty, - }; + return `${h > 0 ? h + ':' : ''}${m.toString().padStart(2, '0')}:${s + .toString() + .padStart(2, '0')}`; } diff --git a/web/packages/teleport/src/Recordings/RecordingsList.tsx b/web/packages/teleport/src/Recordings/RecordingsList.tsx index 076fe27142340..63dc816c66179 100644 --- a/web/packages/teleport/src/Recordings/RecordingsList.tsx +++ b/web/packages/teleport/src/Recordings/RecordingsList.tsx @@ -134,7 +134,7 @@ const renderPlayCell = ( { clusterId, sid }, { recordingType, - durationMs: recordingType === 'desktop' ? duration : undefined, + durationMs: duration, } ); return ( diff --git a/web/packages/teleport/src/Recordings/__snapshots__/Recordings.story.test.tsx.snap b/web/packages/teleport/src/Recordings/__snapshots__/Recordings.story.test.tsx.snap index b925d40dbaed0..d0e35d6403e72 100644 --- a/web/packages/teleport/src/Recordings/__snapshots__/Recordings.story.test.tsx.snap +++ b/web/packages/teleport/src/Recordings/__snapshots__/Recordings.story.test.tsx.snap @@ -915,7 +915,7 @@ exports[`rendering of Session Recordings 1`] = ` > . - */ - -/*eslint no-useless-escape: "off"*/ - -module.exports = { - events: [ - { - 'addr.local': '127.0.0.1:3022', - 'addr.remote': '127.0.0.1:51942', - event: 'session.start', - id: 0, - login: 'akontsevoy', - ms: -64, - offset: 0, - server_id: 'd1d92452-06b8-4828-abad-c1fb7ef947b3', - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '0:0', - time: '2016-05-09T14:57:05.936Z', - user: 'akontsevoy', - }, - { - event: 'resize', - id: 1, - login: 'akontsevoy', - ms: -59, - offset: 0, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '147:20', - time: '2016-05-09T14:57:05.941Z', - user: 'akontsevoy', - }, - { - bytes: 42, - event: 'print', - id: 2, - ms: 87, - offset: 0, - time: '2016-05-09T14:57:06.087Z', - }, - { - bytes: 1, - event: 'print', - id: 3, - ms: 1332, - offset: 42, - time: '2016-05-09T14:57:07.332Z', - }, - { - bytes: 1, - event: 'print', - id: 4, - ms: 1409, - offset: 43, - time: '2016-05-09T14:57:07.409Z', - }, - { - bytes: 2, - event: 'print', - id: 5, - ms: 1670, - offset: 44, - time: '2016-05-09T14:57:07.67Z', - }, - { - bytes: 69, - event: 'print', - id: 6, - ms: 1675, - offset: 46, - time: '2016-05-09T14:57:07.675Z', - }, - { - bytes: 8, - event: 'print', - id: 7, - ms: 1676, - offset: 115, - time: '2016-05-09T14:57:07.676Z', - }, - { - bytes: 32, - event: 'print', - id: 8, - ms: 1769, - offset: 123, - time: '2016-05-09T14:57:07.769Z', - }, - { - bytes: 86, - event: 'print', - id: 9, - ms: 1770, - offset: 155, - time: '2016-05-09T14:57:07.77Z', - }, - { - bytes: 104, - event: 'print', - id: 10, - ms: 1770, - offset: 241, - time: '2016-05-09T14:57:07.77Z', - }, - { - bytes: 99, - event: 'print', - id: 11, - ms: 1771, - offset: 345, - time: '2016-05-09T14:57:07.771Z', - }, - { - bytes: 88, - event: 'print', - id: 12, - ms: 1772, - offset: 444, - time: '2016-05-09T14:57:07.772Z', - }, - { - bytes: 87, - event: 'print', - id: 13, - ms: 1773, - offset: 532, - time: '2016-05-09T14:57:07.773Z', - }, - { - bytes: 4095, - event: 'print', - id: 14, - ms: 1774, - offset: 619, - time: '2016-05-09T14:57:07.774Z', - }, - { - bytes: 1906, - event: 'print', - id: 15, - ms: 1775, - offset: 4714, - time: '2016-05-09T14:57:07.775Z', - }, - { - bytes: 8, - event: 'print', - id: 16, - ms: 1942, - offset: 6620, - time: '2016-05-09T14:57:07.942Z', - }, - { - bytes: 96, - event: 'print', - id: 17, - ms: 1943, - offset: 6628, - time: '2016-05-09T14:57:07.943Z', - }, - { - bytes: 4095, - event: 'print', - id: 18, - ms: 1944, - offset: 6724, - time: '2016-05-09T14:57:07.944Z', - }, - { - bytes: 2080, - event: 'print', - id: 19, - ms: 1945, - offset: 10819, - time: '2016-05-09T14:57:07.945Z', - }, - { - event: 'resize', - id: 20, - login: 'akontsevoy', - ms: 4013, - offset: 12899, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '146:29', - time: '2016-05-09T14:57:10.013Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 21, - ms: 4014, - offset: 12899, - time: '2016-05-09T14:57:10.014Z', - }, - { - bytes: 2227, - event: 'print', - id: 22, - ms: 4017, - offset: 12907, - time: '2016-05-09T14:57:10.017Z', - }, - { - bytes: 2048, - event: 'print', - id: 23, - ms: 4019, - offset: 15134, - time: '2016-05-09T14:57:10.019Z', - }, - { - bytes: 4095, - event: 'print', - id: 24, - ms: 4023, - offset: 17182, - time: '2016-05-09T14:57:10.023Z', - }, - { - bytes: 1037, - event: 'print', - id: 25, - ms: 4025, - offset: 21277, - time: '2016-05-09T14:57:10.025Z', - }, - { - event: 'resize', - id: 26, - login: 'akontsevoy', - ms: 6586, - offset: 22314, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '146:31', - time: '2016-05-09T14:57:12.586Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 27, - ms: 6587, - offset: 22314, - time: '2016-05-09T14:57:12.587Z', - }, - { - bytes: 179, - event: 'print', - id: 28, - ms: 6588, - offset: 22322, - time: '2016-05-09T14:57:12.588Z', - }, - { - bytes: 2048, - event: 'print', - id: 29, - ms: 6589, - offset: 22501, - time: '2016-05-09T14:57:12.589Z', - }, - { - bytes: 4095, - event: 'print', - id: 30, - ms: 6590, - offset: 24549, - time: '2016-05-09T14:57:12.59Z', - }, - { - bytes: 3783, - event: 'print', - id: 31, - ms: 6591, - offset: 28644, - time: '2016-05-09T14:57:12.591Z', - }, - { - event: 'resize', - id: 32, - login: 'akontsevoy', - ms: 8129, - offset: 32427, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '146:25', - time: '2016-05-09T14:57:14.129Z', - user: 'akontsevoy', - }, - { - bytes: 37, - event: 'print', - id: 33, - ms: 8130, - offset: 32427, - time: '2016-05-09T14:57:14.13Z', - }, - { - bytes: 149, - event: 'print', - id: 34, - ms: 8131, - offset: 32464, - time: '2016-05-09T14:57:14.131Z', - }, - { - bytes: 4095, - event: 'print', - id: 35, - ms: 8132, - offset: 32613, - time: '2016-05-09T14:57:14.132Z', - }, - { - bytes: 3737, - event: 'print', - id: 36, - ms: 8133, - offset: 36708, - time: '2016-05-09T14:57:14.133Z', - }, - { - event: 'resize', - id: 37, - login: 'akontsevoy', - ms: 15669, - offset: 40445, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '106:25', - time: '2016-05-09T14:57:21.669Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 38, - ms: 15669, - offset: 40445, - time: '2016-05-09T14:57:21.669Z', - }, - { - bytes: 171, - event: 'print', - id: 39, - ms: 15670, - offset: 40453, - time: '2016-05-09T14:57:21.67Z', - }, - { - bytes: 2048, - event: 'print', - id: 40, - ms: 15671, - offset: 40624, - time: '2016-05-09T14:57:21.671Z', - }, - { - bytes: 4095, - event: 'print', - id: 41, - ms: 15672, - offset: 42672, - time: '2016-05-09T14:57:21.672Z', - }, - { - bytes: 572, - event: 'print', - id: 42, - ms: 15673, - offset: 46767, - time: '2016-05-09T14:57:21.673Z', - }, - { - event: 'resize', - id: 43, - login: 'akontsevoy', - ms: 17085, - offset: 47339, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '76:25', - time: '2016-05-09T14:57:23.085Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 44, - ms: 17086, - offset: 47339, - time: '2016-05-09T14:57:23.086Z', - }, - { - bytes: 176, - event: 'print', - id: 45, - ms: 17087, - offset: 47347, - time: '2016-05-09T14:57:23.087Z', - }, - { - bytes: 4095, - event: 'print', - id: 46, - ms: 17088, - offset: 47523, - time: '2016-05-09T14:57:23.088Z', - }, - { - bytes: 1783, - event: 'print', - id: 47, - ms: 17093, - offset: 51618, - time: '2016-05-09T14:57:23.093Z', - }, - { - event: 'resize', - id: 48, - login: 'akontsevoy', - ms: 18051, - offset: 53401, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '76:25', - time: '2016-05-09T14:57:24.051Z', - user: 'akontsevoy', - }, - { - event: 'resize', - id: 49, - login: 'akontsevoy', - ms: 18532, - offset: 53401, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '150:25', - time: '2016-05-09T14:57:24.532Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 50, - ms: 18535, - offset: 53401, - time: '2016-05-09T14:57:24.535Z', - }, - { - bytes: 4095, - event: 'print', - id: 51, - ms: 18536, - offset: 53409, - time: '2016-05-09T14:57:24.536Z', - }, - { - bytes: 4021, - event: 'print', - id: 52, - ms: 18537, - offset: 57504, - time: '2016-05-09T14:57:24.537Z', - }, - { - bytes: 292, - event: 'print', - id: 53, - ms: 20280, - offset: 61525, - time: '2016-05-09T14:57:26.28Z', - }, - { - bytes: 25, - event: 'print', - id: 54, - ms: 20484, - offset: 61817, - time: '2016-05-09T14:57:26.484Z', - }, - { - bytes: 15, - event: 'print', - id: 55, - ms: 20929, - offset: 61842, - time: '2016-05-09T14:57:26.929Z', - }, - { - bytes: 15, - event: 'print', - id: 56, - ms: 21001, - offset: 61857, - time: '2016-05-09T14:57:27.001Z', - }, - { - bytes: 15, - event: 'print', - id: 57, - ms: 21118, - offset: 61872, - time: '2016-05-09T14:57:27.118Z', - }, - { - event: 'resize', - id: 58, - login: 'akontsevoy', - ms: 27097, - offset: 61887, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '150:33', - time: '2016-05-09T14:57:33.097Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 59, - ms: 27098, - offset: 61887, - time: '2016-05-09T14:57:33.098Z', - }, - { - bytes: 181, - event: 'print', - id: 60, - ms: 27099, - offset: 61895, - time: '2016-05-09T14:57:33.099Z', - }, - { - bytes: 2048, - event: 'print', - id: 61, - ms: 27101, - offset: 62076, - time: '2016-05-09T14:57:33.101Z', - }, - { - bytes: 2048, - event: 'print', - id: 62, - ms: 27101, - offset: 64124, - time: '2016-05-09T14:57:33.101Z', - }, - { - bytes: 4095, - event: 'print', - id: 63, - ms: 27103, - offset: 66172, - time: '2016-05-09T14:57:33.103Z', - }, - { - bytes: 2572, - event: 'print', - id: 64, - ms: 27104, - offset: 70267, - time: '2016-05-09T14:57:33.104Z', - }, - { - event: 'resize', - id: 65, - login: 'akontsevoy', - ms: 28237, - offset: 72839, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '150:24', - time: '2016-05-09T14:57:34.237Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 66, - ms: 28238, - offset: 72839, - time: '2016-05-09T14:57:34.238Z', - }, - { - bytes: 187, - event: 'print', - id: 67, - ms: 28239, - offset: 72847, - time: '2016-05-09T14:57:34.239Z', - }, - { - bytes: 2048, - event: 'print', - id: 68, - ms: 28240, - offset: 73034, - time: '2016-05-09T14:57:34.24Z', - }, - { - bytes: 4095, - event: 'print', - id: 69, - ms: 28241, - offset: 75082, - time: '2016-05-09T14:57:34.241Z', - }, - { - bytes: 1443, - event: 'print', - id: 70, - ms: 28242, - offset: 79177, - time: '2016-05-09T14:57:34.242Z', - }, - { - event: 'resize', - id: 71, - login: 'akontsevoy', - ms: 34805, - offset: 80620, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '86:24', - time: '2016-05-09T14:57:40.805Z', - user: 'akontsevoy', - }, - { - bytes: 37, - event: 'print', - id: 72, - ms: 34806, - offset: 80620, - time: '2016-05-09T14:57:40.806Z', - }, - { - bytes: 147, - event: 'print', - id: 73, - ms: 34807, - offset: 80657, - time: '2016-05-09T14:57:40.807Z', - }, - { - bytes: 2048, - event: 'print', - id: 74, - ms: 34810, - offset: 80804, - time: '2016-05-09T14:57:40.81Z', - }, - { - bytes: 3816, - event: 'print', - id: 75, - ms: 34811, - offset: 82852, - time: '2016-05-09T14:57:40.811Z', - }, - { - event: 'resize', - id: 76, - login: 'akontsevoy', - ms: 35761, - offset: 86668, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '46:24', - time: '2016-05-09T14:57:41.761Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 77, - ms: 35762, - offset: 86668, - time: '2016-05-09T14:57:41.762Z', - }, - { - bytes: 4095, - event: 'print', - id: 78, - ms: 35763, - offset: 86676, - time: '2016-05-09T14:57:41.763Z', - }, - { - bytes: 713, - event: 'print', - id: 79, - ms: 35764, - offset: 90771, - time: '2016-05-09T14:57:41.764Z', - }, - { - event: 'resize', - id: 80, - login: 'akontsevoy', - ms: 36260, - offset: 91484, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '46:24', - time: '2016-05-09T14:57:42.26Z', - user: 'akontsevoy', - }, - { - event: 'resize', - id: 81, - login: 'akontsevoy', - ms: 36408, - offset: 91484, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '60:24', - time: '2016-05-09T14:57:42.408Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 82, - ms: 36409, - offset: 91484, - time: '2016-05-09T14:57:42.409Z', - }, - { - bytes: 170, - event: 'print', - id: 83, - ms: 36410, - offset: 91492, - time: '2016-05-09T14:57:42.41Z', - }, - { - bytes: 4095, - event: 'print', - id: 84, - ms: 36412, - offset: 91662, - time: '2016-05-09T14:57:42.412Z', - }, - { - bytes: 1003, - event: 'print', - id: 85, - ms: 36414, - offset: 95757, - time: '2016-05-09T14:57:42.414Z', - }, - { - event: 'resize', - id: 86, - login: 'akontsevoy', - ms: 36760, - offset: 96760, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '139:24', - time: '2016-05-09T14:57:42.76Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 87, - ms: 36761, - offset: 96760, - time: '2016-05-09T14:57:42.761Z', - }, - { - bytes: 105, - event: 'print', - id: 88, - ms: 36762, - offset: 96768, - time: '2016-05-09T14:57:42.762Z', - }, - { - bytes: 2048, - event: 'print', - id: 89, - ms: 36763, - offset: 96873, - time: '2016-05-09T14:57:42.763Z', - }, - { - bytes: 4095, - event: 'print', - id: 90, - ms: 36764, - offset: 98921, - time: '2016-05-09T14:57:42.764Z', - }, - { - bytes: 1217, - event: 'print', - id: 91, - ms: 36764, - offset: 103016, - time: '2016-05-09T14:57:42.764Z', - }, - { - event: 'resize', - id: 92, - login: 'akontsevoy', - ms: 37417, - offset: 104233, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '147:24', - time: '2016-05-09T14:57:43.417Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 93, - ms: 37426, - offset: 104233, - time: '2016-05-09T14:57:43.426Z', - }, - { - bytes: 4095, - event: 'print', - id: 94, - ms: 37428, - offset: 104241, - time: '2016-05-09T14:57:43.428Z', - }, - { - bytes: 3585, - event: 'print', - id: 95, - ms: 37430, - offset: 108336, - time: '2016-05-09T14:57:43.43Z', - }, - { - event: 'resize', - id: 96, - login: 'akontsevoy', - ms: 37670, - offset: 111921, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '157:24', - time: '2016-05-09T14:57:43.67Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 97, - ms: 37671, - offset: 111921, - time: '2016-05-09T14:57:43.671Z', - }, - { - bytes: 105, - event: 'print', - id: 98, - ms: 37671, - offset: 111929, - time: '2016-05-09T14:57:43.671Z', - }, - { - bytes: 4095, - event: 'print', - id: 99, - ms: 37673, - offset: 112034, - time: '2016-05-09T14:57:43.673Z', - }, - { - bytes: 3749, - event: 'print', - id: 100, - ms: 37674, - offset: 116129, - time: '2016-05-09T14:57:43.674Z', - }, - { - event: 'resize', - id: 101, - login: 'akontsevoy', - ms: 38509, - offset: 119878, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '115:24', - time: '2016-05-09T14:57:44.509Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 102, - ms: 38511, - offset: 119878, - time: '2016-05-09T14:57:44.511Z', - }, - { - bytes: 4095, - event: 'print', - id: 103, - ms: 38512, - offset: 119886, - time: '2016-05-09T14:57:44.512Z', - }, - { - bytes: 2729, - event: 'print', - id: 104, - ms: 38513, - offset: 123981, - time: '2016-05-09T14:57:44.513Z', - }, - { - bytes: 245, - event: 'print', - id: 105, - ms: 42326, - offset: 126710, - time: '2016-05-09T14:57:48.326Z', - }, - { - bytes: 254, - event: 'print', - id: 106, - ms: 42421, - offset: 126955, - time: '2016-05-09T14:57:48.421Z', - }, - { - bytes: 254, - event: 'print', - id: 107, - ms: 42433, - offset: 127209, - time: '2016-05-09T14:57:48.433Z', - }, - { - bytes: 250, - event: 'print', - id: 108, - ms: 42451, - offset: 127463, - time: '2016-05-09T14:57:48.451Z', - }, - { - event: 'resize', - id: 109, - login: 'akontsevoy', - ms: 44256, - offset: 127713, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '115:34', - time: '2016-05-09T14:57:50.256Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 110, - ms: 44258, - offset: 127713, - time: '2016-05-09T14:57:50.258Z', - }, - { - bytes: 29, - event: 'print', - id: 111, - ms: 44259, - offset: 127721, - time: '2016-05-09T14:57:50.259Z', - }, - { - bytes: 149, - event: 'print', - id: 112, - ms: 44259, - offset: 127750, - time: '2016-05-09T14:57:50.259Z', - }, - { - bytes: 2048, - event: 'print', - id: 113, - ms: 44260, - offset: 127899, - time: '2016-05-09T14:57:50.26Z', - }, - { - bytes: 4095, - event: 'print', - id: 114, - ms: 44261, - offset: 129947, - time: '2016-05-09T14:57:50.261Z', - }, - { - bytes: 3681, - event: 'print', - id: 115, - ms: 44262, - offset: 134042, - time: '2016-05-09T14:57:50.262Z', - }, - { - event: 'resize', - id: 116, - login: 'akontsevoy', - ms: 45237, - offset: 137723, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - size: '115:23', - time: '2016-05-09T14:57:51.237Z', - user: 'akontsevoy', - }, - { - bytes: 8, - event: 'print', - id: 117, - ms: 45238, - offset: 137723, - time: '2016-05-09T14:57:51.238Z', - }, - { - bytes: 182, - event: 'print', - id: 118, - ms: 45239, - offset: 137731, - time: '2016-05-09T14:57:51.239Z', - }, - { - bytes: 2048, - event: 'print', - id: 119, - ms: 45240, - offset: 137913, - time: '2016-05-09T14:57:51.24Z', - }, - { - bytes: 4095, - event: 'print', - id: 120, - ms: 45242, - offset: 139961, - time: '2016-05-09T14:57:51.242Z', - }, - { - bytes: 183, - event: 'print', - id: 121, - ms: 45243, - offset: 144056, - time: '2016-05-09T14:57:51.243Z', - }, - { - event: 'session.leave', - id: 122, - ms: 46403, - offset: 144239, - server_id: 'd1d92452-06b8-4828-abad-c1fb7ef947b3', - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - time: '2016-05-09T14:57:52.403Z', - user: 'akontsevoy', - }, - { - event: 'session.end', - id: 123, - sid: '4bac8c61-15f6-11e6-a2e6-f0def19340e2', - time: '2016-05-09T15:00:52.403Z', - user: 'akontsevoy', - }, - ], - - data: `]0;akontsevoy@x220: ~akontsevoy@x220:~$ mc -[?1049h(B(B - -[?1049l7[?47h[?1001s[?1002h[?1006h[?2004h[?1049h[?1h=(B | / ┐ - \ ┐]0;mc [akontsevoy@x220]:~>Hint: To look at the output of a command in the viewer, use M-!]0;mc [akontsevoy@x220]:~>]0;mc [akontsevoy@x220]:~>]0;mc [akontsevoy@x220]:~> Left File Command Options Right -┌<─ ~ ────────────────────────────.[^]>┐┌<─ ~ ────────────────────────────.[^]> -│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-cre~l-cache(B│ 4096(B│Mar 19 16:18(B││/.git-cre~l-cache(B│ 4096(B│Mar 19 16:18(B│├──────────────────────────────────────┤├──────────────────────────────────────┤│UP--DIR ││UP--DIR │└──────────────────── 116G/219G (52%) ─┘└──────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$[^] 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov 7(BMkdir  8(BDelete 9(BPullDn10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h= [^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ──────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B│├───────────────────────────────────────────────────────────────────────┤├────────────────────────────────────────────────────────────────────────┤│UP--DIR ││UP--DIR │└───────────────────────────────────────────────────── 116G/219G (52%) ─┘└────────────────────────────────────────────────────── 116G/219G (52%) ─┘Hint: To look at the output of a command in the viewer, use M-! -akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Do you want lynx-style navigation? Set it in the Configuration dialog. [^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.gvm (B│ 4096(B│Nov 28 16:48(B│├───────────────────────────────────────────────────────────────────────┤├───────────────────────────────────────────────────────────────────────┤│UP--DIR ││UP--DIR │└───────────────────────────────────────────────────── 116G/219G (52%) ─┘└───────────────────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Selecting directories: add a slash to the end of the matching pattern. [^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.hplip (B│ 4096(B│Mar 7 21:38(B││/.hplip (B│ 4096(B│Mar 7 21:38(B││/.lastpass (B│ 4096(B│Dec 14 16:01(B││/.lastpass (B│ 4096(B│Dec 14 16:01(B│├───────────────────────────────────────────────────────────────────────┤├───────────────────────────────────────────────────────────────────────┤│UP--DIR ││UP--DIR │└───────────────────────────────────────────────────── 116G/219G (52%) ─┘└───────────────────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Find File: you can work on the files found using the Panelize button. [^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B│├───────────────────────────────────────────────────────────────────────┤├───────────────────────────────────────────────────────────────────────┤│UP--DIR ││UP--DIR │└───────────────────────────────────────────────────── 116G/219G (52%) ─┘└───────────────────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: VFS coolness: tap enter on a tar file to examine its contents. [^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────.[^]>┐┌<─ ~ ─────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B│├───────────────────────────────────────────────────┤├───────────────────────────────────────────────────┤│UP--DIR ││UP--DIR │└───────────────────────────────── 116G/219G (52%) ─┘└───────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Completion works on all input lines in all dialogs. Just press M-Tab. [^] Left File Command Options Right -┌<─ ~ ──────────────────────────.[^]>┐┌<─ ~ ──────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-cr~-cache(B│ 4096(B│Mar 19 16:18(B││/.git-cr~-cache(B│ 4096(B│Mar 19 16:18(B│├────────────────────────────────────┤├────────────────────────────────────┤│UP--DIR ││UP--DIR │└────────────────── 116G/219G (52%) ─┘└────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRe~ov 7(BMkdir 8(BDelete 9(BPullDn10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Want to do complex searches? Use the External Panelize command. [^] Left File Command Options Right -┌<─ ~ ───────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ───────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. │UP--DIR│Oct 2 2015││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B│├─────────────────────────────────────────────────────────────────────────┤├─────────────────────────────────────────────────────────────────────────┤│UP--DIR ││UP--DIR │└─────────────────────────────────────────────────────── 116G/219G (52%) ─┘└─────────────────────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit/.. (B│UP--DIR(B│Oct 2 2015(B/.gimp-2.8 │ 4096│Apr 25 22:55/.gimp-2.8gdfg7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: You can specify the username when doing ftps: 'cd ftp://user@machine.edu' gdfg[^] Left File Command Options Right -┌<─ ~ ───────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ───────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.hplip (B│ 4096(B│Mar 7 21:38(B││/.hplip (B│ 4096(B│Mar 7 21:38(B││/.lastpass (B│ 4096(B│Dec 14 16:01(B││/.lastpass (B│ 4096(B│Dec 14 16:01(B││/.local (B│ 4096(B│Apr 8 15:25(B││/.local (B│ 4096(B│Apr 8 15:25(B││/.macromedia (B│ 4096(B│Oct 2 2015(B││/.macromedia (B│ 4096(B│Oct 2 2015(B│├─────────────────────────────────────────────────────────────────────────┤├─────────────────────────────────────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└─────────────────────────────────────────────────────── 116G/219G (52%) ─┘└─────────────────────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Find File: you can work on the files found using the Panelize button. gdfg[^] Left File Command Options Right -┌<─ ~ ───────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ───────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B│├─────────────────────────────────────────────────────────────────────────┤├─────────────────────────────────────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└─────────────────────────────────────────────────────── 116G/219G (52%) ─┘└─────────────────────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Need to quote a character? Use Control-q and the character. gdfg[^] Left File Command Options Right -┌<─ ~ ───────────────────────────────.[^]>┐┌<─ ~ ───────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B│├─────────────────────────────────────────┤├─────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└─────────────────────── 116G/219G (52%) ─┘└─────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov 7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: You may specify the external viewer with gdfg[^] Left File Command Options Right -┌<─ ~ ───────────.[^]>┐┌<─ ~ ───────────.[^]>┐│.nName (B│Siz(B│Modify t(B││.nName (B│Siz(B│Modify t(B││/.. (B│DIR(B│ 2 2015(B││/.. (B│DIR(B│ 2 2015(B││/.Skype (B│ 4K(B│ 9 10:51(B││/.Skype (B│ 4K(B│ 9 10:51(B││/.adobe (B│ 4K(B│ 2 2015(B││/.adobe (B│ 4K(B│ 2 2015(B││/.an~ble(B│ 4K(B│24 20:35(B││/.an~ble(B│ 4K(B│24 20:35(B││/.ap~ude(B│ 4K(B│28 14:51(B││/.ap~ude(B│ 4K(B│28 14:51(B││/.atom (B│ 4K(B│ 4 2015(B││/.atom (B│ 4K(B│ 4 2015(B││/.aws (B│ 4K(B│ 8 16:09(B││/.aws (B│ 4K(B│ 8 16:09(B││/.cache (B│ 4K(B│ 7 15:56(B││/.cache (B│ 4K(B│ 7 15:56(B││/.config(B│ 4K(B│ 7 22:22(B││/.config(B│ 4K(B│ 7 22:22(B││/.dbus (B│ 4K(B│ 2 2015(B││/.dbus (B│ 4K(B│ 2 2015(B││/.dlv (B│ 4K(B│10 14:15(B││/.dlv (B│ 4K(B│10 14:15(B││/.em~s.d(B│ 4K(B│20 09:29(B││/.em~s.d(B│ 4K(B│20 09:29(B││/.fo~ver(B│ 4K(B│20 15:29(B││/.fo~ver(B│ 4K(B│20 15:29(B││/.gconf (B│ 4K(B│ 7 22:22(B││/.gconf (B│ 4K(B│ 7 22:22(B││/.gi~2.8│ 4K│25 22:55││/.gi~2.8(B│ 4K(B│25 22:55(B│├─────────────────────┤├─────────────────────┤│/.gimp-2.8 ││UP--DIR │└─── 116G/219G (52%) ─┘└─── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRe~ov7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Are some of your keys not working? Look at Options/Lea gdfg[^] Left File Command Options Right -┌<─ ~ ──────────────────.[^]>┐┌<─ ~ ──────────────────.[^]>┐│.n Name (B│Size (B│Modify tim(B││.n Name (B│Size (B│Modify tim(B││/.. (B│--DIR(B│t 2 2015(B││/.. (B│--DIR(B│t 2 2015(B││/.Skype (B│ 4096(B│y 9 10:51(B││/.Skype (B│ 4096(B│y 9 10:51(B││/.adobe (B│ 4096(B│t 2 2015(B││/.adobe (B│ 4096(B│t 2 2015(B││/.ansible (B│ 4096(B│r 24 20:35(B││/.ansible (B│ 4096(B│r 24 20:35(B││/.aptitude (B│ 4096(B│r 28 14:51(B││/.aptitude (B│ 4096(B│r 28 14:51(B││/.atom (B│ 4096(B│v 4 2015(B││/.atom (B│ 4096(B│v 4 2015(B││/.aws (B│ 4096(B│r 8 16:09(B││/.aws (B│ 4096(B│r 8 16:09(B││/.cache (B│ 4096(B│y 7 15:56(B││/.cache (B│ 4096(B│y 7 15:56(B││/.config (B│ 4096(B│y 7 22:22(B││/.config (B│ 4096(B│y 7 22:22(B││/.dbus (B│ 4096(B│v 2 2015(B││/.dbus (B│ 4096(B│v 2 2015(B││/.dlv (B│ 4096(B│n 10 14:15(B││/.dlv (B│ 4096(B│n 10 14:15(B││/.emacs.d (B│ 4096(B│n 20 09:29(B││/.emacs.d (B│ 4096(B│n 20 09:29(B││/.forever (B│ 4096(B│n 20 15:29(B││/.forever (B│ 4096(B│n 20 15:29(B││/.gconf (B│ 4096(B│y 7 22:22(B││/.gconf (B│ 4096(B│y 7 22:22(B││/.gimp-2.8 │ 4096│r 25 22:55││/.gimp-2.8 (B│ 4096(B│r 25 22:55(B│├────────────────────────────┤├────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└────────── 116G/219G (52%) ─┘└────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRe~ov 7(BMkdir 8(BDe~te7[?47h]0;mc [akontsevoy@x220]:~>[?1h= gdfg[^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ──────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B│├───────────────────────────────────────────────────────────────────┤├────────────────────────────────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└───────────────────────────────────────────────── 116G/219G (52%) ─┘└────────────────────────────────────────────────── 116G/219G (52%) ─┘Hint: Are some of your keys not working? Look at Options/Learn keys. -akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h= gdfg[^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ──────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B│├───────────────────────────────────────────────────────────────────────┤├────────────────────────────────────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└───────────────────────────────────────────────────── 116G/219G (52%) ─┘└────────────────────────────────────────────────────── 116G/219G (52%) ─┘Hint: Are some of your keys not working? Look at Options/Learn keys. -akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h= gdfg[^] Left File Command Options Right -┌<─ ~ ──────────────────────────────────────────────────────────────────.[^]>┐┌<─ ~ ───────────────────────────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B│├────────────────────────────────────────────────────────────────────────────┤├─────────────────────────────────────────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└────────────────────────────────────────────────────────── 116G/219G (52%) ─┘└─────────────────────────────────────────────────────────── 116G/219G (52%) ─┘Hint: Are some of your keys not working? Look at Options/Learn keys. -akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: Leap to frequently used directories in a single bound with C-\. gdfg[^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────.[^]>┐┌<─ ~ ──────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 │ 4096│Apr 25 22:55││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B│├───────────────────────────────────────────────────────┤├────────────────────────────────────────────────────────┤│/.gimp-2.8 ││UP--DIR │└───────────────────────────────────── 116G/219G (52%) ─┘└────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit/.forever │ 4096│Jan 20 15:29/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(Bforever /.emacs.d │ 4096│Jan 20 09:29/.forever (B│ 4096(B│Jan 20 15:29(Bemacs.d/.dlv │ 4096│Jan 10 14:15/.emacs.d (B│ 4096(B│Jan 20 09:29(Bdlv /.dbus │ 4096│Nov 2 2015/.dlv (B│ 4096(B│Jan 10 14:15(Bbus7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: To mark directories on the select dialog box, append a slash. gdfg[^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────.[^]>┐┌<─ ~ ──────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus │ 4096│Nov 2 2015││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.gimp-2.8 (B│ 4096(B│Apr 25 22:55(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.git-credential-cache (B│ 4096(B│Mar 19 16:18(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnome (B│ 4096(B│Apr 7 22:06(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gnupg (B│ 4096(B│Dec 14 14:23(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvfs (B│ 4096(B│Nov 2 2015(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.gvm (B│ 4096(B│Nov 28 16:48(B││/.hplip (B│ 4096(B│Mar 7 21:38(B││/.hplip (B│ 4096(B│Mar 7 21:38(B││/.lastpass (B│ 4096(B│Dec 14 16:01(B││/.lastpass (B│ 4096(B│Dec 14 16:01(B││/.local (B│ 4096(B│Apr 8 15:25(B││/.local (B│ 4096(B│Apr 8 15:25(B││/.macromedia (B│ 4096(B│Oct 2 2015(B││/.macromedia (B│ 4096(B│Oct 2 2015(B││/.mozilla (B│ 4096(B│Oct 2 2015(B││/.mozilla (B│ 4096(B│Oct 2 2015(B│├───────────────────────────────────────────────────────┤├────────────────────────────────────────────────────────┤│/.dbus ││UP--DIR │└───────────────────────────────────── 116G/219G (52%) ─┘└────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit7[?47h]0;mc [akontsevoy@x220]:~>[?1h=Hint: You may specify the editor for F4 with the shell variable EDITOR. gdfg[^] Left File Command Options Right -┌<─ ~ ─────────────────────────────────────────────.[^]>┐┌<─ ~ ──────────────────────────────────────────────.[^]>┐│.n Name (B│ Size (B│Modify time (B││.n Name (B│ Size (B│Modify time (B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.. (B│UP--DIR(B│Oct 2 2015(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.Skype (B│ 4096(B│May 9 10:51(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.adobe (B│ 4096(B│Oct 2 2015(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.ansible (B│ 4096(B│Mar 24 20:35(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.aptitude (B│ 4096(B│Apr 28 14:51(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.atom (B│ 4096(B│Nov 4 2015(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.aws (B│ 4096(B│Apr 8 16:09(B││/.cache (B│ 4096(B│May 7 15:56(B││/.cache (B│ 4096(B│May 7 15:56(B││/.config (B│ 4096(B│May 7 22:22(B││/.config (B│ 4096(B│May 7 22:22(B││/.dbus │ 4096│Nov 2 2015││/.dbus (B│ 4096(B│Nov 2 2015(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.dlv (B│ 4096(B│Jan 10 14:15(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.emacs.d (B│ 4096(B│Jan 20 09:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.forever (B│ 4096(B│Jan 20 15:29(B││/.gconf (B│ 4096(B│May 7 22:22(B││/.gconf (B│ 4096(B│May 7 22:22(B│├───────────────────────────────────────────────────────┤├────────────────────────────────────────────────────────┤│/.dbus ││UP--DIR │└───────────────────────────────────── 116G/219G (52%) ─┘└────────────────────────────────────── 116G/219G (52%) ─┘akontsevoy@x220:~$ - 1(BHelp  2(BMenu  3(BView  4(BEdit  5(BCopy  6(BRenMov  7(BMkdir  8(BDelete  9(BPullDn 10(BQuit`, -}; diff --git a/web/packages/teleport/src/lib/term/terminal.ts b/web/packages/teleport/src/lib/term/terminal.ts index 6ce0d17bf14b8..77fb4c28acb3b 100644 --- a/web/packages/teleport/src/lib/term/terminal.ts +++ b/web/packages/teleport/src/lib/term/terminal.ts @@ -158,7 +158,11 @@ export default class TtyTerminal { _processData(data) { try { this.tty.pauseFlow(); - this.term.write(data, () => this.tty.resumeFlow()); + + // during a live session, data is emitted as a string. + // during playback, data from the websocket comes over as a DataView + const d: any = typeof data === 'string' ? data : new Uint8Array(data); + this.term.write(d, () => this.tty.resumeFlow()); } catch (err) { logger.error('xterm.write', data, err); // recover xtermjs by resetting it diff --git a/web/packages/teleport/src/lib/term/tty.ts b/web/packages/teleport/src/lib/term/tty.ts index 98f8257073a2e..88a18bcc5d246 100644 --- a/web/packages/teleport/src/lib/term/tty.ts +++ b/web/packages/teleport/src/lib/term/tty.ts @@ -181,6 +181,7 @@ class Tty extends EventEmitterWebAuthnSender { try { const uintArray = new Uint8Array(ev.data); const msg = this._proto.decode(uintArray); + switch (msg.type) { case MessageTypeEnum.WEBAUTHN_CHALLENGE: this.emit(TermEvent.WEBAUTHN_CHALLENGE, msg.payload); diff --git a/web/packages/teleport/src/lib/term/ttyAddressResolver.js b/web/packages/teleport/src/lib/term/ttyAddressResolver.js index 59abdfcf02fd3..efd04dc28a2ed 100644 --- a/web/packages/teleport/src/lib/term/ttyAddressResolver.js +++ b/web/packages/teleport/src/lib/term/ttyAddressResolver.js @@ -16,7 +16,11 @@ * along with this program. If not, see . */ -export default class AddressResolver { +/** + * LiveSessionAddressResolver is an address resolver that computes + * a URL to start a new web-based SSH session. + */ +export default class LiveSessionAddressResolver { _cfg = { ttyUrl: null, ttyParams: {}, diff --git a/web/packages/teleport/src/lib/term/ttyPlayer.js b/web/packages/teleport/src/lib/term/ttyPlayer.js index ee5d2b96b6d83..3b33b33c65d31 100644 --- a/web/packages/teleport/src/lib/term/ttyPlayer.js +++ b/web/packages/teleport/src/lib/term/ttyPlayer.js @@ -16,263 +16,235 @@ * along with this program. If not, see . */ -import BufferModule from 'buffer/'; +import { throttle } from 'shared/utils/highbar'; import Logger from 'shared/libs/logger'; import Tty from './tty'; -import { TermEvent } from './enums'; -import { onlyPrintEvents } from './ttyPlayerEventProvider'; +import { TermEvent, WebsocketCloseCode } from './enums'; const logger = Logger.create('TtyPlayer'); -const STREAM_START_INDEX = 0; -const PLAY_SPEED = 10; -export const Buffer = BufferModule.Buffer; export const StatusEnum = { PLAYING: 'PLAYING', ERROR: 'ERROR', PAUSED: 'PAUSED', LOADING: 'LOADING', + COMPLETE: 'COMPLETE', }; -export default class TtyPlayer extends Tty { - constructor(eventProvider) { - super({}); - this.currentEventIndex = 0; - this.current = 0; - this.duration = 0; - this.status = StatusEnum.LOADING; - this.statusText = ''; - - this._posToEventIndexMap = []; - this._eventProvider = eventProvider; - - // _chunkQueue is a list of data chunks waiting to be rendered by the term. - this._chunkQueue = []; - // _writeInFlight prevents sending more data to xterm while a prior render has not finished yet. - this._writeInFlight = false; - } - - // override - send() {} - - // override - connect() { - this.status = StatusEnum.LOADING; - this._change(); - return this._eventProvider - .init() - .then(() => { - this._init(); - this.status = StatusEnum.PAUSED; - }) - .catch(err => { - logger.error('unable to init event provider', err); - this._handleError(err); - }) - .finally(this._change.bind(this)); - } +const messageTypePty = 1; +const messageTypeError = 2; +const messageTypePlayPause = 3; +const messageTypeSeek = 4; +const messageTypeResize = 5; - pauseFlow() { - this._writeInFlight = true; - } +const actionPlay = 0; +const actionPause = 1; - resumeFlow() { - this._writeInFlight = false; - this._chunkDequeue(); - } - - move(newPos) { - if (!this.isReady()) { - return; - } - - if (newPos === undefined) { - newPos = this.current + 1; - } +// we update the time every time we receive data, or +// at this interval (which ensures that the progress +// bar updates even when we aren't receiving data) +const PROGRESS_UPDATE_INTERVAL_MS = 50; - if (newPos < 0) { - newPos = 0; - } +export default class TtyPlayer extends Tty { + constructor({ url, setPlayerStatus, setStatusText, setTime }) { + super({}); - if (newPos > this.duration) { - this.stop(); - } + this._url = url; + this._setPlayerStatus = setPlayerStatus; + this._setStatusText = setStatusText; - const newEventIndex = this._getEventIndex(newPos) + 1; + this._paused = false; + this._lastPlayedTimestamp = 0; - if (newEventIndex === this.currentEventIndex) { - this.current = newPos; - this._change(); - return; - } + this._sendTimeUpdates = true; + this._setTime = throttle(t => setTime(t), PROGRESS_UPDATE_INTERVAL_MS); + this._lastUpdate = 0; + this._timeout = null; + } - const isRewind = this.currentEventIndex > newEventIndex; + // Override the base class connection, which uses the envelope-based + // websocket protocol (this protocol doesn't support sending timing data). + connect() { + this._setPlayerStatus(StatusEnum.LOADING); - try { - // we cannot playback the content within terminal so instead: - // 1. tell terminal to reset. - // 2. tell terminal to render 1 huge chunk that has everything up to current - // location. - if (isRewind) { - this._chunkQueue = []; - this.emit(TermEvent.RESET); - } + this.webSocket = new WebSocket(this._url); + this.webSocket.binaryType = 'arraybuffer'; + this.webSocket.onopen = () => this.emit('open'); + this.webSocket.onmessage = m => this.onMessage(m); + this.webSocket.onclose = e => { + logger.debug('websocket closed', e); + this.cancelTimeUpdate(); - const from = isRewind ? 0 : this.currentEventIndex; - const to = newEventIndex; - const events = this._eventProvider.events.slice(from, to); - const printEvents = events.filter(onlyPrintEvents); + this.webSocket.close(); + this.webSocket.onopen = null; + this.webSocket.onclose = null; + this.webSocket.onmessage = null; + this.webSocket = null; - this._render(printEvents); - this.currentEventIndex = newEventIndex; - this.current = newPos; - this._change(); - } catch (err) { - logger.error('move', err); - this._handleError(err); - } + this.emit(TermEvent.CONN_CLOSE, e); + this._setPlayerStatus(StatusEnum.COMPLETE); + }; } - stop() { - this.status = StatusEnum.PAUSED; - this.timer = clearInterval(this.timer); - this._change(); + suspendTimeUpdates() { + this._sendTimeUpdates = false; } - play() { - if (this.status === StatusEnum.PLAYING) { - return; - } - - this.status = StatusEnum.PLAYING; - // start from the beginning if reached the end of the session - if (this.current >= this.duration) { - this.current = STREAM_START_INDEX; - this.emit(TermEvent.RESET); - } - - this.timer = setInterval(this.move.bind(this), PLAY_SPEED); - this._change(); + resumeTimeUpdates() { + this._sendTimeUpdates = true; } - getCurrentTime() { - if (this.currentEventIndex) { - let { displayTime } = - this._eventProvider.events[this.currentEventIndex - 1]; - return displayTime; - } else { - return '--:--'; + setTime(t) { + // time updates are suspended when a user is dragging the slider to + // a new position (it's very disruptive if we're updating the slider + // position every few milliseconds while the user is trying to + // reposition it) + if (this._sendTimeUpdates) { + this._setTime(t); } } - getEventCount() { - return this._eventProvider.events.length; + disconnect(closeCode = WebsocketCloseCode.NORMAL) { + this.cancelTimeUpdate(); + if (this.webSocket !== null) { + this.webSocket.close(closeCode); + } } - isLoading() { - return this.status === StatusEnum.LOADING; - } + scheduleNextUpdate(current) { + this._timeout = setTimeout(() => { + const delta = Date.now() - this._lastUpdate; + const next = current + delta; + this.setTime(next); + this._lastUpdate = Date.now(); - isPlaying() { - return this.status === StatusEnum.PLAYING; + this.scheduleNextUpdate(next); + }, PROGRESS_UPDATE_INTERVAL_MS); } - isError() { - return this.status === StatusEnum.ERROR; + cancelTimeUpdate() { + if (this._timeout != null) { + clearTimeout(this._timeout); + this._timeout = null; + } } - isReady() { - return ( - this.status !== StatusEnum.LOADING && this.status !== StatusEnum.ERROR - ); + onMessage(m) { + try { + const dv = new DataView(m.data); + const typ = dv.getUint8(0); + const len = dv.getUint16(1); + + // see lib/web/tty_playback.go for details on this protocol + switch (typ) { + case messageTypePty: + this.cancelTimeUpdate(); + + const delay = Number(dv.getBigUint64(3)); + const data = dv.buffer.slice( + dv.byteOffset + 11, + dv.byteOffset + 11 + len + ); + + this.emit(TermEvent.DATA, data); + this._lastPlayedTimestamp = delay; + + this._lastUpdate = Date.now(); + this.setTime(delay); + + // schedule the next time update (in case this + // part of the recording is dead time) + // TODO(zmb3): implement this for desktops too + if (!this._paused) { + this.scheduleNextUpdate(delay); + } + break; + + case messageTypeError: + // ignore the severity byte at index 3 (we display all errors identically) + const msgLen = dv.getUint16(4); + const msg = new TextDecoder().decode( + dv.buffer.slice(dv.byteOffset + 6, dv.byteOffset + 6 + msgLen) + ); + this._setStatusText(msg); + this._setPlayerStatus(StatusEnum.ERROR); + this.disconnect(); + return; + + case messageTypeResize: + const w = dv.getUint16(3); + const h = dv.getUint16(5); + this.emit(TermEvent.RESIZE, { w, h }); + return; + + default: + logger.warn('unexpected message type', typ); + return; + } + } catch (err) { + logger.error('failed to parse incoming message', err); + } } - disconnect() { - // do nothing - } + // override + send() {} + pauseFlow() {} + resumeFlow() {} - _init() { - this.duration = this._eventProvider.getDuration(); - this._eventProvider.events.forEach(item => - this._posToEventIndexMap.push(item.msNormalized) - ); - } + move(newPos) { + this.cancelTimeUpdate(); - _chunkDequeue() { - const chunk = this._chunkQueue.shift(); - if (!chunk) { - return; + try { + const buffer = new ArrayBuffer(11); + const dv = new DataView(buffer); + dv.setUint8(0, messageTypeSeek); + dv.setUint16(1, 8 /* length */); + dv.setBigUint64(3, BigInt(newPos)); + this.webSocket.send(dv); + } catch (e) { + logger.error('error seeking', e); } - const str = chunk.data.join(''); - this.emit(TermEvent.RESIZE, { h: chunk.h, w: chunk.w }); - this.emit(TermEvent.DATA, str); - } - - _render(events) { - if (!events || events.length === 0) { - return; + if (newPos < this._lastPlayedTimestamp) { + this.emit(TermEvent.RESET); + } else if (this._paused) { + // if we're paused, we want the scrubber to "stick" at the new + // time until we press play (rather than waiting for us to click + // play and start receiving new data) + this._setTime(newPos); } + } - const groups = [ - { - data: [events[0].data], - w: events[0].w, - h: events[0].h, - }, - ]; - - let cur = groups[0]; - - // group events by screen size and construct 1 chunk of data per group - for (let i = 1; i < events.length; i++) { - if (cur.w === events[i].w && cur.h === events[i].h) { - cur.data.push(events[i].data); - } else { - cur = { - data: [events[i].data], - w: events[i].w, - h: events[i].h, - }; - - groups.push(cur); - } - } + stop() { + this._paused = true; + this.cancelTimeUpdate(); + this._setPlayerStatus(StatusEnum.PAUSED); - this._chunkQueue = [...this._chunkQueue, ...groups]; - if (!this._writeInFlight) { - this._chunkDequeue(); - } + const buffer = new ArrayBuffer(4); + const dv = new DataView(buffer); + dv.setUint8(0, messageTypePlayPause); + dv.setUint16(1, 1 /* size */); + dv.setUint8(3, actionPause); + this.webSocket.send(dv); } - _getEventIndex(num) { - const arr = this._posToEventIndexMap; - var low = 0; - var hi = arr.length - 1; - - while (hi - low > 1) { - const mid = Math.floor((low + hi) / 2); - if (arr[mid] < num) { - low = mid; - } else { - hi = mid; - } - } + play() { + this._paused = false; + this._setPlayerStatus(StatusEnum.PLAYING); - if (num - arr[low] <= arr[hi] - num) { - return low; + // the very first play call happens before we've even + // connected - we only need to send the websocket message + // for subsequent calls + if (this.webSocket.readyState !== WebSocket.OPEN) { + return; } - return hi; - } - - _change() { - this.emit('change'); - } - - _handleError(err) { - this.status = StatusEnum.ERROR; - this.statusText = err.message; + const buffer = new ArrayBuffer(4); + const dv = new DataView(buffer); + dv.setUint8(0, messageTypePlayPause); + dv.setUint16(1, 1 /* size */); + dv.setUint8(3, actionPlay); + this.webSocket.send(dv); } } diff --git a/web/packages/teleport/src/lib/term/ttyPlayer.test.js b/web/packages/teleport/src/lib/term/ttyPlayer.test.js index b2de31641c315..569d3f3e3b0ae 100644 --- a/web/packages/teleport/src/lib/term/ttyPlayer.test.js +++ b/web/packages/teleport/src/lib/term/ttyPlayer.test.js @@ -17,247 +17,127 @@ */ import '@gravitational/shared/libs/polyfillFinally'; -import api from 'teleport/services/api'; -import { TermEvent } from 'teleport/lib/term/enums'; - -import TtyPlayer, { Buffer } from './ttyPlayer'; -import EventProvider, { MAX_SIZE } from './ttyPlayerEventProvider'; -import sample from './fixtures/streamData'; - -describe('lib/term/ttyPlayer/eventProvider', () => { - afterEach(function () { - jest.clearAllMocks(); - }); - - it('should create an instance', () => { - const provider = new EventProvider({ url: 'sample.com' }); - expect(provider.events).toEqual([]); - }); +import WS from 'jest-websocket-mock'; - it('should load events and initialize itself', async () => { - const provider = new EventProvider({ url: 'sample.com' }); - - jest.spyOn(api, 'get').mockImplementation(() => Promise.resolve(sample)); - jest.spyOn(provider, '_createEvents'); - jest.spyOn(provider, '_normalizeEventsByTime'); - jest - .spyOn(provider, '_fetchContent') - .mockImplementation(() => Promise.resolve()); - jest.spyOn(provider, '_populatePrintEvents').mockImplementation(); - - await provider.init(); - - expect(api.get).toHaveBeenCalledWith('sample.com/events'); - expect(provider._createEvents).toHaveBeenCalledWith(sample.events); - expect(provider._normalizeEventsByTime).toHaveBeenCalled(); - expect(provider._fetchContent).toHaveBeenCalled(); - expect(provider._populatePrintEvents).toHaveBeenCalled(); - }); - - it('should create event objects', () => { - const provider = new EventProvider({ url: 'sample.com' }); - const events = provider._createEvents(sample.events); - const eventObj = { - eventType: 'print', - displayTime: '00:45', - ms: 4523, - bytes: 6516, - offset: 137723, - data: null, - w: 115, - h: 23, - time: new Date('2016-05-09T14:57:51.238Z'), - msNormalized: 1744, - }; - - expect(events).toHaveLength(32); - expect(events[30]).toEqual(eventObj); - }); - - it('should fetch session content', async () => { - const provider = new EventProvider({ url: 'sample.com' }); - - jest - .spyOn(provider, '_fetchEvents') - .mockImplementation(() => - Promise.resolve(provider._createEvents(sample.events)) - ); - - jest - .spyOn(api, 'fetch') - .mockImplementation(() => Promise.resolve({ text: () => sample.data })); - - await provider.init(); +import { TermEvent } from 'teleport/lib/term/enums'; - expect(api.fetch).toHaveBeenCalledWith( - `sample.com/stream?offset=0&bytes=${MAX_SIZE}`, - { - Accept: 'text/plain', - 'Content-Type': 'text/plain; charset=utf-8', - } - ); - - const buf = new Buffer(sample.data); - const lastEvent = provider.events[provider.events.length - 2]; - const expectedChunk = buf - .slice(lastEvent.offset, lastEvent.offset + lastEvent.bytes) - .toString('utf8'); - expect(lastEvent.data).toEqual(expectedChunk); - }); -}); +import TtyPlayer, { StatusEnum } from './ttyPlayer'; describe('lib/ttyPlayer', () => { - afterEach(() => { - jest.clearAllMocks(); + let server; + const url = 'ws://localhost:3088'; + beforeEach(() => { + server = new WS(url); }); + afterEach(() => { + WS.clean(); - it('should create an instance', () => { - const ttyPlayer = new TtyPlayer({ url: 'testSid' }); - expect(ttyPlayer.isReady()).toBe(false); - expect(ttyPlayer.isPlaying()).toBe(false); - expect(ttyPlayer.isError()).toBe(false); - expect(ttyPlayer.isLoading()).toBe(true); - expect(ttyPlayer.duration).toBe(0); - expect(ttyPlayer.current).toBe(0); - }); - - it('should connect using event provider', async () => { - const ttyPlayer = new TtyPlayer(new EventProvider({ url: 'testSid' })); - - jest.spyOn(api, 'get').mockImplementation(() => Promise.resolve(sample)); - jest - .spyOn(ttyPlayer._eventProvider, '_fetchContent') - .mockImplementation(() => Promise.resolve(sample.data)); - - await ttyPlayer.connect(); - - expect(ttyPlayer.isReady()).toBe(true); - expect(ttyPlayer.getEventCount()).toBe(32); - }); - - it('should indicate its loading status', async () => { - const ttyPlayer = new TtyPlayer(new EventProvider({ url: 'testSid' })); - jest - .spyOn(api, 'get') - .mockImplementation(() => Promise.resolve({ events: [] })); - - ttyPlayer.connect(); - expect(ttyPlayer.isLoading()).toBe(true); - }); - - it('should indicate its error status', async () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - jest.spyOn(api, 'get').mockImplementation(() => Promise.reject('!!')); - - const ttyPlayer = new TtyPlayer(new EventProvider({ url: 'testSid' })); - - await ttyPlayer.connect(); - expect(ttyPlayer.isError()).toBe(true); + jest.clearAllMocks(); }); - describe('move()', () => { - var tty = null; - - beforeEach(() => { - tty = new TtyPlayer(new EventProvider({ url: 'testSid' })); - jest.spyOn(api, 'get').mockImplementation(() => Promise.resolve(sample)); - jest - .spyOn(tty._eventProvider, '_fetchContent') - .mockImplementation(() => Promise.resolve(sample.data)); - }); + it('connects to a websocket', async () => { + const setPlayerStatus = jest.fn(); + const setStatusText = () => {}; + const setTime = () => {}; - afterEach(function () { - jest.clearAllMocks(); + const ttyPlayer = new TtyPlayer({ + url, + setPlayerStatus, + setStatusText, + setTime, }); + const emit = jest.spyOn(ttyPlayer, 'emit'); - it('should move by 1 position when called w/o params', async () => { - await tty.connect(); - - let renderedData = ''; - tty.on(TermEvent.DATA, data => { - renderedData = data; - }); - - tty.move(); - expect(renderedData).toHaveLength(42); - }); - - it('should move from 1 to 478 position (forward)', async () => { - await tty.connect(); + ttyPlayer.connect(); + await server.connected; - const renderedDataLength = []; - const resizeEvents = []; + expect(setPlayerStatus.mock.calls).toHaveLength(1); + expect(setPlayerStatus.mock.calls[0][0]).toBe(StatusEnum.LOADING); - tty.on(TermEvent.RESIZE, event => { - resizeEvents.push(event); - }); + expect(emit.mock.calls).toHaveLength(1); + expect(emit.mock.calls[0][0]).toBe('open'); - tty.on(TermEvent.DATA, data => { - renderedDataLength.push(data.length); - tty.resumeFlow(); - }); + server.close(); + await server.closed; - tty.move(478); + expect(emit.mock.calls).toHaveLength(2); + expect(emit.mock.calls[1][0]).toBe(TermEvent.CONN_CLOSE); + }); - const expected = [ - { - resize: { - h: 20, - w: 147, - }, - length: 12899, - }, - { - resize: { - h: 29, - w: 146, - }, - length: 9415, - }, - { - resize: { - h: 31, - w: 146, - }, - length: 10113, - }, - { - resize: { - h: 25, - w: 146, - }, - length: 8018, - }, - ]; + it('emits resize events', async () => { + const setPlayerStatus = () => {}; + const setStatusText = () => {}; + const setTime = () => {}; - for (let i = 0; i < expected.length; i++) { - expect(resizeEvents[i]).toEqual(expected[i].resize); - expect(renderedDataLength[i]).toBe(expected[i].length); - } + const ttyPlayer = new TtyPlayer({ + url, + setPlayerStatus, + setStatusText, + setTime, }); + const emit = jest.spyOn(ttyPlayer, 'emit'); - it('should move from 478 to 1 position (back)', async () => { - await tty.connect(); + ttyPlayer.connect(); + await server.connected; + + const resizeMessage = new Uint8Array([ + 5, // message type = Resize + 0, + 4, // size + 0, + 80, // width + 0, + 60, // height + ]); + + server.send(resizeMessage.buffer); + + expect(emit.mock.lastCall).toBeDefined(); + expect(emit.mock.lastCall[0]).toBe(TermEvent.RESIZE); + expect(emit.mock.lastCall[1]).toStrictEqual({ w: 80, h: 60 }); + }); - let renderedData = ''; - tty.current = 478; - tty.on(TermEvent.DATA, data => { - renderedData = data; - }); + it('plays PTY data', async () => { + const setPlayerStatus = jest.fn(); + const setStatusText = jest.fn(); + const setTime = jest.fn(); - tty.move(2); - expect(renderedData).toHaveLength(42); + const ttyPlayer = new TtyPlayer({ + url, + setPlayerStatus, + setStatusText, + setTime, }); + const emit = jest.spyOn(ttyPlayer, 'emit'); - it('should stop playing if new position is greater than session length', async () => { - await tty.connect(); - tty.play(); - - const someBigNumber = 20000; - tty.move(someBigNumber); - - expect(tty.isPlaying()).toBe(false); - }); + ttyPlayer.connect(); + await server.connected; + + const data = new TextEncoder('utf-8').encode('~/test $'); + const len = data.length + 8; + const ptyMessage = new Uint8Array([ + 1 /* message type = PTY */, + len >> 8, + len & 0xff /* length */, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 123 /* timestamp (123ms) */, + ...data, + ]); + + server.send(ptyMessage.buffer); + + expect(emit.mock.lastCall).toBeDefined(); + expect(emit.mock.lastCall[0]).toBe(TermEvent.DATA); + + expect(emit.mock.lastCall[1]).toStrictEqual(Uint8Array.from(data).buffer); + expect(setTime.mock.lastCall).toBeDefined(); + expect(setTime.mock.lastCall[0]).toBe(123); }); }); diff --git a/web/packages/teleport/src/lib/term/ttyPlayerEventProvider.js b/web/packages/teleport/src/lib/term/ttyPlayerEventProvider.js deleted file mode 100644 index fdacbcee6783e..0000000000000 --- a/web/packages/teleport/src/lib/term/ttyPlayerEventProvider.js +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Teleport - * Copyright (C) 2023 Gravitational, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import BufferModule from 'buffer/'; - -import api from 'teleport/services/api'; - -import { EventType } from './enums'; - -const URL_PREFIX_EVENTS = '/events'; -const Buffer = BufferModule.Buffer; - -export const MAX_SIZE = 5242880; // 5mg - -export default class EventProvider { - constructor({ url }) { - this.url = url; - this.events = []; - } - - getDuration() { - const eventCount = this.events.length; - if (eventCount === 0) { - return 0; - } - - return this.events[eventCount - 1].msNormalized; - } - - init() { - return this._fetchEvents().then(events => { - this.events = events; - const printEvents = this.events.filter(onlyPrintEvents); - if (printEvents.length === 0) { - return; - } - - return this._fetchContent(printEvents).then(buffer => { - this._populatePrintEvents(buffer, printEvents); - }); - }); - } - - _fetchEvents() { - const url = this.url + URL_PREFIX_EVENTS; - return api.get(url).then(json => { - if (!json.events) { - return []; - } - - return this._createEvents(json.events); - }); - } - - _fetchContent(events) { - // calculate the size of the session in bytes to know how many - // chunks to load due to maximum chunk size limitation. - let offset = events[0].offset; - const end = events.length - 1; - const totalSize = events[end].offset - offset + events[end].bytes; - const chunkCount = Math.ceil(totalSize / MAX_SIZE); - - // create a fetch request for each chunk - const promises = []; - for (let i = 0; i < chunkCount; i++) { - const url = `${this.url}/stream?offset=${offset}&bytes=${MAX_SIZE}`; - promises.push( - api - .fetch(url, { - Accept: 'text/plain', - 'Content-Type': 'text/plain; charset=utf-8', - }) - .then(response => response.text()) - ); - offset = offset + MAX_SIZE; - } - - // fetch all chunks and then merge - return Promise.all(promises).then(responses => { - const allBytes = responses.reduce((byteStr, r) => byteStr + r, ''); - return new Buffer(allBytes); - }); - } - - // assign a slice of tty stream to corresponding print event - _populatePrintEvents(buffer, events) { - let byteStrOffset = events[0].bytes; - events[0].data = buffer.slice(0, byteStrOffset).toString('utf8'); - for (var i = 1; i < events.length; i++) { - let { bytes } = events[i]; - events[i].data = buffer - .slice(byteStrOffset, byteStrOffset + bytes) - .toString('utf8'); - byteStrOffset += bytes; - } - } - - _createEvents(json) { - let w, h; - let events = []; - - // filter print events and ensure that each has the right screen size and valid values - for (let i = 0; i < json.length; i++) { - const { ms, event, offset, time, bytes } = json[i]; - - // grab new screen size for the next events - if (event === EventType.RESIZE || event === EventType.START) { - [w, h] = json[i].size.split(':'); - } - - // session has ended, stop here - if (event === EventType.END) { - const start = new Date(events[0].time); - const end = new Date(time); - const duration = end.getTime() - start.getTime(); - events.push({ - eventType: event, - ms: duration, - time: new Date(time), - }); - - break; - } - - // process only PRINT events - if (event !== EventType.PRINT) { - continue; - } - - events.push({ - eventType: EventType.PRINT, - ms, - bytes, - offset, - data: null, - w: Number(w), - h: Number(h), - time: new Date(time), - }); - } - - return this._normalizeEventsByTime(events); - } - - _normalizeEventsByTime(events) { - if (!events || events.length === 0) { - return []; - } - - events.forEach(e => { - e.displayTime = formatDisplayTime(e.ms); - e.ms = e.ms > 0 ? Math.floor(e.ms / 10) : 0; - e.msNormalized = e.ms; - }); - - let cur = events[0]; - let tmp = []; - for (let i = 1; i < events.length; i++) { - const sameSize = cur.w === events[i].w && cur.h === events[i].h; - const delay = events[i].ms - cur.ms; - - // merge events with tiny delay - if (delay < 2 && sameSize) { - cur.bytes += events[i].bytes; - continue; - } - - // avoid long delays between chunks - events[i].msNormalized = cur.msNormalized + shortenTime(delay); - - tmp.push(cur); - cur = events[i]; - } - - if (tmp.indexOf(cur) === -1) { - tmp.push(cur); - } - - return tmp; - } -} - -function shortenTime(value) { - if (value >= 25 && value < 50) { - return 25; - } else if (value >= 50 && value < 100) { - return 50; - } else if (value >= 100) { - return 100; - } else { - return value; - } -} - -function formatDisplayTime(ms) { - if (ms <= 0) { - return '00:00'; - } - - let totalSec = Math.floor(ms / 1000); - let totalDays = (totalSec % 31536000) % 86400; - let h = Math.floor(totalDays / 3600); - let m = Math.floor((totalDays % 3600) / 60); - let s = (totalDays % 3600) % 60; - - m = m > 9 ? m : '0' + m; - s = s > 9 ? s : '0' + s; - h = h > 0 ? h + ':' : ''; - - return `${h}${m}:${s}`; -} - -export function onlyPrintEvents(e) { - return e.eventType === EventType.PRINT; -} diff --git a/yarn.lock b/yarn.lock index 736d3cf841893..16fa035777abb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10862,7 +10862,7 @@ jest-config@^29.7.0: slash "^3.0.0" strip-json-comments "^3.1.1" -jest-diff@^29.7.0: +jest-diff@^29.2.0, jest-diff@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== @@ -11141,6 +11141,14 @@ jest-watcher@^29.7.0: jest-util "^29.7.0" string-length "^4.0.1" +jest-websocket-mock@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/jest-websocket-mock/-/jest-websocket-mock-2.5.0.tgz#9e0b07e270bed0224a6d3269fc62625eaa4d465c" + integrity sha512-a+UJGfowNIWvtIKIQBHoEWIUqRxxQHFx4CXT+R5KxxKBtEQ5rS3pPOV/5299sHzqbmeCzxxY5qE4+yfXePePig== + dependencies: + jest-diff "^29.2.0" + mock-socket "^9.3.0" + jest-worker@^26.5.0: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed" @@ -12499,6 +12507,11 @@ mkdirp@^1.0.3, mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +mock-socket@^9.3.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/mock-socket/-/mock-socket-9.3.1.tgz#24fb00c2f573c84812aa4a24181bb025de80cc8e" + integrity sha512-qxBgB7Qa2sEQgHFjj0dSigq7fX4k6Saisd5Nelwp2q8mlbAFh5dHV9JTTlF8viYJLSSWgMCZFUom8PJcMNBoJw== + module-details-from-path@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/module-details-from-path/-/module-details-from-path-1.0.3.tgz#114c949673e2a8a35e9d35788527aa37b679da2b" @@ -16848,9 +16861,9 @@ webpack-virtual-modules@^0.4.1: integrity sha512-5NUqC2JquIL2pBAAo/VfBP6KuGkHIZQXW/lNKupLPfhViwh8wNsu0BObtl09yuKZszeEUfbXz8xhrHvSG16Nqw== webpack@4, "webpack@>=4.43.0 <6.0.0", webpack@^5, webpack@^5.76.2, webpack@^5.88.2, webpack@^5.9.0: - version "5.88.2" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.88.2.tgz#f62b4b842f1c6ff580f3fcb2ed4f0b579f4c210e" - integrity sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ== + version "5.89.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.89.0.tgz#56b8bf9a34356e93a6625770006490bf3a7f32dc" + integrity sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw== dependencies: "@types/eslint-scope" "^3.7.3" "@types/estree" "^1.0.0"