diff --git a/lib/client/api.go b/lib/client/api.go
index 83529816c9e40..89009f5daa1c5 100644
--- a/lib/client/api.go
+++ b/lib/client/api.go
@@ -5260,19 +5260,6 @@ func findActiveDatabases(key *Key) ([]tlsca.RouteToDatabase, error) {
return databases, nil
}
-// getDesktopEventWebURL returns the web UI URL users can access to
-// watch a desktop session recording in the browser
-func getDesktopEventWebURL(proxyHost string, cluster string, sid *session.ID, events []events.EventFields) string {
- if len(events) < 1 {
- return ""
- }
- start := events[0].GetTimestamp()
- end := events[len(events)-1].GetTimestamp()
- duration := end.Sub(start)
-
- return fmt.Sprintf("https://%s/web/cluster/%s/session/%s?recordingType=desktop&durationMs=%d", proxyHost, cluster, sid, duration/time.Millisecond)
-}
-
// SearchSessionEvents allows searching for session events with a full pagination support.
func (tc *TeleportClient) SearchSessionEvents(ctx context.Context, fromUTC, toUTC time.Time, pageSize int, order types.EventOrder, max int) ([]apievents.AuditEvent, error) {
ctx, span := tc.Tracer.Start(
diff --git a/lib/client/api_test.go b/lib/client/api_test.go
index f1cd5a344fa48..9012a93494565 100644
--- a/lib/client/api_test.go
+++ b/lib/client/api_test.go
@@ -27,7 +27,6 @@ import (
"math"
"os"
"testing"
- "time"
"github.com/google/go-cmp/cmp"
"github.com/gravitational/trace"
@@ -42,9 +41,7 @@ import (
"github.com/gravitational/teleport/api/utils/grpc/interceptors"
"github.com/gravitational/teleport/api/utils/keys"
"github.com/gravitational/teleport/lib/defaults"
- "github.com/gravitational/teleport/lib/events"
"github.com/gravitational/teleport/lib/observability/tracing"
- "github.com/gravitational/teleport/lib/session"
"github.com/gravitational/teleport/lib/utils"
)
@@ -899,78 +896,6 @@ func TestFormatConnectToProxyErr(t *testing.T) {
}
}
-func TestGetDesktopEventWebURL(t *testing.T) {
- initDate := time.Date(2021, 1, 1, 12, 0, 0, 0, time.UTC)
-
- tt := []struct {
- name string
- proxyHost string
- cluster string
- sid session.ID
- events []events.EventFields
- expected string
- }{
- {
- name: "nil events",
- events: nil,
- expected: "",
- },
- {
- name: "empty events",
- events: make([]events.EventFields, 0),
- expected: "",
- },
- {
- name: "two events, 1000 ms duration",
- proxyHost: "host",
- cluster: "cluster",
- sid: "session_id",
- events: []events.EventFields{
- {
- "time": initDate,
- },
- {
- "time": initDate.Add(1000 * time.Millisecond),
- },
- },
- expected: "https://host/web/cluster/cluster/session/session_id?recordingType=desktop&durationMs=1000",
- },
- {
- name: "multiple events",
- proxyHost: "host",
- cluster: "cluster",
- sid: "session_id",
- events: []events.EventFields{
- {
- "time": initDate,
- },
- {
- "time": initDate.Add(10 * time.Millisecond),
- },
- {
- "time": initDate.Add(20 * time.Millisecond),
- },
- {
- "time": initDate.Add(30 * time.Millisecond),
- },
- {
- "time": initDate.Add(40 * time.Millisecond),
- },
- {
- "time": initDate.Add(50 * time.Millisecond),
- },
- },
- expected: "https://host/web/cluster/cluster/session/session_id?recordingType=desktop&durationMs=50",
- },
- }
-
- for _, tc := range tt {
- t.Run(tc.name, func(t *testing.T) {
- require.Equal(t, tc.expected, getDesktopEventWebURL(tc.proxyHost, tc.cluster, &tc.sid, tc.events))
- })
- }
-}
-
type mockRoleGetter func(ctx context.Context) ([]types.Role, error)
func (m mockRoleGetter) GetRoles(ctx context.Context) ([]types.Role, error) {
diff --git a/lib/events/auditlog.go b/lib/events/auditlog.go
index 9a434a35438b6..ba121c2221a08 100644
--- a/lib/events/auditlog.go
+++ b/lib/events/auditlog.go
@@ -1024,6 +1024,7 @@ func (l *AuditLog) StreamSessionEvents(ctx context.Context, sessionID session.ID
}
protoReader := NewProtoReader(rawSession)
+ defer protoReader.Close()
for {
if ctx.Err() != nil {
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index d63448f3860dd..3a58a2f3ce1d0 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -834,6 +834,7 @@ func (h *Handler) bindDefaultEndpoints() {
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/ttyplayback/:sid", h.WithClusterAuth(h.ttyPlaybackHandle))
+ h.GET("/webapi/sites/:site/sessionlength/:sid", h.WithClusterAuth(h.sessionLengthHandle))
// 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)
diff --git a/lib/web/tty_playback.go b/lib/web/tty_playback.go
index 232ec37e525bf..d308fdb1ab139 100644
--- a/lib/web/tty_playback.go
+++ b/lib/web/tty_playback.go
@@ -53,6 +53,45 @@ const (
actionPause = byte(1)
)
+func (h *Handler) sessionLengthHandle(
+ 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")
+ }
+
+ ctx, cancel := context.WithCancel(r.Context())
+ defer cancel()
+
+ clt, err := sctx.GetUserClient(ctx, site)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ evts, errs := clt.StreamSessionEvents(ctx, session.ID(sID), 0)
+ for {
+ select {
+ case err := <-errs:
+ return nil, trace.Wrap(err)
+ case evt, ok := <-evts:
+ if !ok {
+ return nil, trace.NotFound("could not find end event for session %v", sID)
+ }
+ switch evt := evt.(type) {
+ case *events.SessionEnd:
+ return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil
+ case *events.WindowsDesktopSessionEnd:
+ return map[string]any{"durationMs": evt.EndTime.Sub(evt.StartTime).Milliseconds()}, nil
+ }
+ }
+ }
+}
+
func (h *Handler) ttyPlaybackHandle(
w http.ResponseWriter,
r *http.Request,
diff --git a/web/packages/teleport/src/Player/Player.tsx b/web/packages/teleport/src/Player/Player.tsx
index 7e5012660098e..74f26bfdc43a2 100644
--- a/web/packages/teleport/src/Player/Player.tsx
+++ b/web/packages/teleport/src/Player/Player.tsx
@@ -16,21 +16,23 @@
* along with this program. If not, see .
*/
-import React from 'react';
-import styled from 'styled-components';
+import React, { useCallback, useEffect } from 'react';
-import { Flex, Box } from 'design';
+import styled from 'styled-components';
+import { Flex, Box, Indicator } from 'design';
import { Danger } from 'design/Alert';
-import { useParams, useLocation } from 'teleport/components/Router';
+import { makeSuccessAttempt, useAsync } from 'shared/hooks/useAsync';
+import { useParams, useLocation } from 'teleport/components/Router';
import session from 'teleport/services/websession';
import { UrlPlayerParams } from 'teleport/config';
import { getUrlParameter } from 'teleport/services/history';
-
import { RecordingType } from 'teleport/services/recordings';
+import useTeleport from 'teleport/useTeleport';
+
import ActionBar from './ActionBar';
import { DesktopPlayer } from './DesktopPlayer';
import SshPlayer from './SshPlayer';
@@ -39,19 +41,44 @@ import Tabs, { TabItem } from './PlayerTabs';
const validRecordingTypes = ['ssh', 'k8s', 'desktop'];
export function Player() {
+ const ctx = useTeleport();
const { sid, clusterId } = useParams();
const { search } = useLocation();
+ useEffect(() => {
+ document.title = `Play ${sid} • ${clusterId}`;
+ }, [sid, clusterId]);
+
const recordingType = getUrlParameter(
'recordingType',
search
) as RecordingType;
- const durationMs = Number(getUrlParameter('durationMs', search));
+
+ // In order to render the progress bar, we need to know the length of the session.
+ // All in-product links to the session player should include the session duration in the URL.
+ // Some users manually build the URL based on the session ID and don't specify the session duration.
+ // For those cases, we make a separate API call to get the duration.
+ const [fetchDurationAttempt, fetchDuration] = useAsync(
+ useCallback(
+ () => ctx.recordingsService.fetchRecordingDuration(clusterId, sid),
+ [ctx.recordingsService, clusterId, sid]
+ )
+ );
const validRecordingType = validRecordingTypes.includes(recordingType);
- const validDurationMs = Number.isInteger(durationMs) && durationMs > 0;
+ const durationMs = Number(getUrlParameter('durationMs', search));
+ const shouldFetchSessionDuration =
+ validRecordingType && (!Number.isInteger(durationMs) || durationMs <= 0);
+
+ useEffect(() => {
+ if (shouldFetchSessionDuration) {
+ fetchDuration();
+ }
+ }, [fetchDuration, shouldFetchSessionDuration]);
- document.title = `Play ${sid} • ${clusterId}`;
+ const combinedAttempt = shouldFetchSessionDuration
+ ? fetchDurationAttempt
+ : makeSuccessAttempt({ durationMs });
function onLogout() {
session.logout();
@@ -70,13 +97,25 @@ export function Player() {
);
}
- if (!validDurationMs) {
+ if (
+ combinedAttempt.status === '' ||
+ combinedAttempt.status === 'processing'
+ ) {
+ return (
+
+
+
+
+
+ );
+ }
+ if (combinedAttempt.status === 'error') {
return (
- Invalid query parameter durationMs:{' '}
- {getUrlParameter('durationMs', search)}, should be an integer.
+ Unable to determine the length of this session. The session
+ recording may be incomplete or corrupted.
@@ -102,15 +141,20 @@ export function Player() {
) : (
-
+
)}
);
}
+
const StyledPlayer = styled.div`
display: flex;
height: 100%;
diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts
index 914c2fc65c7bf..2fbe5d00c0d80 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -19,9 +19,7 @@
import { generatePath } from 'react-router';
import { mergeDeep } from 'shared/utils/highbar';
-import generateResourcePath from './generateResourcePath';
-
-import type {
+import {
Auth2faType,
AuthProvider,
AuthType,
@@ -29,10 +27,12 @@ import type {
PrimaryAuthType,
} from 'shared/services';
+import generateResourcePath from './generateResourcePath';
+
import type { SortType } from 'teleport/services/agents';
import type { RecordingType } from 'teleport/services/recordings';
-import type { WebauthnAssertionResponse } from './services/auth';
-import type { Regions } from './services/integrations';
+import type { WebauthnAssertionResponse } from 'teleport/services/auth';
+import type { Regions } from 'teleport/services/integrations';
import type { ParticipantMode } from 'teleport/services/session';
import type { YamlSupportedResourceKind } from './services/yaml/types';
@@ -236,6 +236,7 @@ const cfg = {
ttyPlaybackWsAddr:
'wss://:fqdn/v1/webapi/sites/:clusterId/ttyplayback/:sid?access_token=:token', // TODO(zmb3): get token out of URL
activeAndPendingSessionsPath: '/v1/webapi/sites/:clusterId/sessions',
+ sessionDurationPath: '/v1/webapi/sites/:clusterId/sessionlength/:sid',
// TODO(zmb3): remove this for v15
sshPlaybackPrefix: '/v1/webapi/sites/:clusterId/sessions/:sid', // prefix because this is eventually concatenated with "/stream" or "/events"
@@ -701,6 +702,10 @@ const cfg = {
return generatePath(cfg.api.activeAndPendingSessionsPath, { clusterId });
},
+ getSessionDurationUrl(clusterId: string, sid: string) {
+ return generatePath(cfg.api.sessionDurationPath, { clusterId, sid });
+ },
+
getUnifiedResourcesUrl(clusterId: string, params: UrlResourcesParams) {
return generateResourcePath(cfg.api.unifiedResourcesPath, {
clusterId,
diff --git a/web/packages/teleport/src/services/mfa/types.ts b/web/packages/teleport/src/services/mfa/types.ts
index 401bee906d3b5..5fcf7436cae21 100644
--- a/web/packages/teleport/src/services/mfa/types.ts
+++ b/web/packages/teleport/src/services/mfa/types.ts
@@ -17,8 +17,7 @@
*/
import { WebauthnAssertionResponse } from '../auth';
-import { DeviceUsage } from '../auth/types';
-import { CreateNewHardwareDeviceRequest } from '../auth/types';
+import { CreateNewHardwareDeviceRequest, DeviceUsage } from '../auth/types';
export interface MfaDevice {
id: string;
diff --git a/web/packages/teleport/src/services/recordings/recordings.ts b/web/packages/teleport/src/services/recordings/recordings.ts
index e27ca67beea03..ba71160aa1795 100644
--- a/web/packages/teleport/src/services/recordings/recordings.ts
+++ b/web/packages/teleport/src/services/recordings/recordings.ts
@@ -45,4 +45,11 @@ export default class RecordingsService {
return { recordings: events.map(makeRecording), startKey: json.startKey };
});
}
+
+ fetchRecordingDuration(
+ clusterId: string,
+ sessionId: string
+ ): Promise<{ durationMs: number }> {
+ return api.get(cfg.getSessionDurationUrl(clusterId, sessionId));
+ }
}