[Web] Add support for Moderated Sessions in the Web UI (#20782)

This commit is contained in:
Yassine Bounekhla 2023-01-26 18:18:26 -05:00 committed by GitHub
parent b1d62883de
commit 4a90ff4632
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 262 additions and 47 deletions

View file

@ -98,4 +98,5 @@ const session: Session = {
clusterId: '',
parties: [],
addr: '1.1.1.1:1111',
participantModes: ['observer', 'moderator', 'peer'],
};

View file

@ -25,12 +25,16 @@ import ConsoleContext from 'teleport/Console/consoleContext';
import { useConsoleContext } from 'teleport/Console/consoleContextProvider';
import { DocumentSsh } from 'teleport/Console/stores';
import type { Session, SessionMetadata } from 'teleport/services/session';
import type {
ParticipantMode,
Session,
SessionMetadata,
} from 'teleport/services/session';
const tracer = trace.getTracer('TTY');
export default function useSshSession(doc: DocumentSsh) {
const { clusterId, sid, serverId, login } = doc;
const { clusterId, sid, serverId, login, mode } = doc;
const ctx = useConsoleContext();
const ttyRef = React.useRef<Tty>(null);
const tty = ttyRef.current as ReturnType<typeof ctx.createTty>;
@ -43,13 +47,13 @@ export default function useSshSession(doc: DocumentSsh) {
React.useEffect(() => {
// initializes tty instances
function initTty(session) {
function initTty(session, mode?: ParticipantMode) {
tracer.startActiveSpan(
'initTTY',
undefined, // SpanOptions
context.active(),
span => {
const tty = ctx.createTty(session);
const tty = ctx.createTty(session, mode);
// subscribe to tty events to handle connect/disconnects events
tty.on(TermEvent.CLOSE, () => ctx.closeTab(doc));
@ -79,12 +83,15 @@ export default function useSshSession(doc: DocumentSsh) {
ttyRef.current && ttyRef.current.removeAllListeners();
}
initTty({
login,
serverId,
clusterId,
sid,
});
initTty(
{
login,
serverId,
clusterId,
sid,
},
mode
);
return cleanup;

View file

@ -28,6 +28,7 @@ import TtyAddressResolver from 'teleport/lib/term/ttyAddressResolver';
import serviceSession, {
Session,
ParticipantList,
ParticipantMode,
} from 'teleport/services/session';
import serviceNodes from 'teleport/services/nodes';
import serviceClusters from 'teleport/services/clusters';
@ -83,13 +84,14 @@ export default class ConsoleContext {
});
}
addSshDocument({ login, serverId, sid, clusterId }: UrlSshParams) {
addSshDocument({ login, serverId, sid, clusterId, mode }: UrlSshParams) {
const title = login && serverId ? `${login}@${serverId}` : sid;
const url = this.getSshDocumentUrl({
clusterId,
login,
serverId,
sid,
mode,
});
return this.storeDocs.add({
@ -101,6 +103,7 @@ export default class ConsoleContext {
login,
sid,
url,
mode,
created: new Date(),
});
}
@ -170,7 +173,7 @@ export default class ConsoleContext {
webSession.logout();
}
createTty(session: Session): Tty {
createTty(session: Session, mode?: ParticipantMode): Tty {
const { login, sid, serverId, clusterId } = session;
const propagator = new W3CTraceContextPropagator();
@ -192,6 +195,7 @@ export default class ConsoleContext {
login,
sid,
server_id: serverId,
mode,
},
});

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Participant } from 'teleport/services/session';
import type { Participant, ParticipantMode } from 'teleport/services/session';
interface DocumentBase {
id?: number;
@ -33,6 +33,7 @@ export interface DocumentSsh extends DocumentBase {
status: 'connected' | 'disconnected';
kind: 'terminal';
sid?: string;
mode?: ParticipantMode;
serverId: string;
login: string;
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 Gravitational, Inc.
Copyright 2019-2022 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -18,11 +18,12 @@ import React from 'react';
import { useRouteMatch, useParams, useLocation } from 'react-router';
import cfg, { UrlSshParams } from 'teleport/config';
import { ParticipantMode } from 'teleport/services/session';
import ConsoleContext from './consoleContext';
export default function useRouting(ctx: ConsoleContext) {
const { pathname } = useLocation();
const { pathname, search } = useLocation();
const { clusterId } = useParams<{ clusterId: string }>();
const sshRouteMatch = useRouteMatch<UrlSshParams>(cfg.routes.consoleConnect);
const nodesRouteMatch = useRouteMatch(cfg.routes.consoleNodes);
@ -41,6 +42,12 @@ export default function useRouting(ctx: ConsoleContext) {
if (sshRouteMatch) {
ctx.addSshDocument(sshRouteMatch.params);
} else if (joinSshRouteMatch) {
// Extract the mode param from the URL if it is present.
const searchParams = new URLSearchParams(search);
const mode = searchParams.get('mode');
if (mode) {
joinSshRouteMatch.params.mode = mode as ParticipantMode;
}
ctx.addSshDocument(joinSshRouteMatch.params);
} else if (nodesRouteMatch) {
ctx.addNodeDocument(clusterId);

View file

@ -0,0 +1,52 @@
/*
Copyright 2023 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import { render, screen, fireEvent } from 'design/utils/testing';
import { SessionJoinBtn } from './SessionJoinBtn';
test('all participant modes are properly listed and in the correct order', () => {
render(
<SessionJoinBtn
sid={'4b038eda-ddca-5533-9a49-3a34f133b5f4'}
clusterId={'test-cluster'}
participantModes={['moderator', 'peer', 'observer']}
/>
);
const joinBtn = screen.queryByText(/Join/i);
expect(joinBtn).toBeInTheDocument();
fireEvent.click(joinBtn);
// Make sure that the join URL is correct.
const moderatorJoinUrl = screen
.queryByText('moderator')
.closest('a')
.getAttribute('href');
expect(moderatorJoinUrl).toBe(
'/web/cluster/test-cluster/console/session/4b038eda-ddca-5533-9a49-3a34f133b5f4?mode=moderator'
);
// Make sure that the menu items are in the order of observer -> moderator -> peer.
const menuItems = screen.queryAllByRole<HTMLAnchorElement>('link');
expect(menuItems).toHaveLength(3);
expect(menuItems[0].innerHTML).toBe('observer');
expect(menuItems[1].innerHTML).toBe('moderator');
expect(menuItems[2].innerHTML).toBe('peer');
});

View file

@ -0,0 +1,98 @@
/*
Copyright 2023 Gravitational, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useState } from 'react';
import { ButtonBorder, Text, Box, Menu, MenuItem } from 'design';
import { CarrotDown } from 'design/Icon';
import cfg from 'teleport/config';
import { ParticipantMode } from 'teleport/services/session';
export const SessionJoinBtn = ({
sid,
clusterId,
participantModes,
}: {
sid: string;
clusterId: string;
participantModes: ParticipantMode[];
}) => {
// Sorts the list of participantModes so that they are consistently shown in the order of "observer" -> "moderator" -> "peer"
const modes = {
observer: 1,
moderator: 2,
peer: 3,
};
const sortedParticipantModes = participantModes.sort(
(a, b) => modes[a] - modes[b]
);
return (
<JoinMenu>
{sortedParticipantModes.map(participantMode => (
<MenuItem
key={participantMode}
as="a"
href={cfg.getSshSessionRoute({ sid, clusterId }, participantMode)}
target="_blank"
style={{ textTransform: 'capitalize' }}
>
{participantMode}
</MenuItem>
))}
</JoinMenu>
);
};
function JoinMenu({ children }: { children: React.ReactNode }) {
const [anchorEl, setAnchorEl] = useState<HTMLElement>(null);
const handleClickListItem = event => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<Box textAlign="center" width="80px">
<ButtonBorder size="small" onClick={handleClickListItem}>
Join
<CarrotDown ml={1} fontSize={2} color="text.secondary" />
</ButtonBorder>
<Menu
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center',
}}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<Text px="2" fontSize="11px" color="grey.400" bg="subtle">
Join as...
</Text>
{children}
</Menu>
</Box>
);
}

View file

@ -14,15 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { ButtonBorder } from 'design';
import Table, { Cell } from 'design/DataTable';
import Icon, * as Icons from 'design/Icon/Icon';
import React from 'react';
import styled from 'styled-components';
import cfg from 'teleport/config';
import { Participant, Session, SessionKind } from 'teleport/services/session';
import { SessionJoinBtn } from './SessionJoinBtn';
export default function SessionList(props: Props) {
const { sessions, pageSize = 100 } = props;
@ -102,25 +102,24 @@ const renderIconCell = (kind: SessionKind) => {
);
};
const renderJoinCell = ({ sid, clusterId, kind }: Session) => {
const renderJoinCell = ({
sid,
clusterId,
kind,
participantModes,
}: Session) => {
const { joinable } = kinds[kind];
if (!joinable) {
if (!joinable || participantModes.length === 0) {
return <Cell align="right" height="26px" />;
}
const url = cfg.getSshSessionRoute({ sid, clusterId });
return (
<Cell align="right" height="26px">
<ButtonBorder
kind="primary"
as="a"
href={url}
width="80px"
target="_blank"
size="small"
>
Join
</ButtonBorder>
<SessionJoinBtn
sid={sid}
clusterId={clusterId}
participantModes={participantModes}
/>
</Cell>
);
};

View file

@ -2,6 +2,12 @@
exports[`loaded 1`] = `
.c18 {
box-sizing: border-box;
width: 80px;
text-align: center;
}
.c19 {
line-height: 1.5;
margin: 0;
display: inline-flex;
@ -20,28 +26,31 @@ exports[`loaded 1`] = `
text-transform: uppercase;
transition: all 0.3s;
-webkit-font-smoothing: antialiased;
background: #512FC9;
background: #2C3A73;
border: 1px solid #1C254D;
opacity: .87;
color: rgba(255,255,255,0.87);
font-size: 10px;
min-height: 24px;
padding: 0px 16px;
width: 80px;
}
.c18:active {
.c19:active {
opacity: 0.56;
}
.c18:hover,
.c18:focus {
background: #651FFF;
.c19:hover,
.c19:focus {
background: #2C3A73;
border: 1px solid rgba(255,255,255,0.1);
opacity: 1;
}
.c18:active {
background: #354AA4;
.c19:active {
opacity: 0.24;
}
.c18:disabled {
.c19:disabled {
background: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.3);
}
@ -68,6 +77,14 @@ exports[`loaded 1`] = `
font-size: 16px;
}
.c20 {
display: inline-block;
transition: color 0.3s;
margin-left: 4px;
color: rgba(255,255,255,0.56);
font-size: 14px;
}
.c9 {
overflow: hidden;
text-overflow: ellipsis;
@ -486,15 +503,22 @@ exports[`loaded 1`] = `
align="right"
height="26px"
>
<a
<div
class="c18"
href="/web/cluster/im-a-cluster-name/console/session/c7befbb4-3885-4d08-a466-de832a73c3d4"
kind="primary"
target="_blank"
width="80px"
>
Join
</a>
<button
class="c19"
kind="border"
>
Join
<span
class="c12 c20 icon icon-caret-down "
color="text.secondary"
font-size="2"
/>
</button>
</div>
</td>
</tr>
<tr>

View file

@ -33,6 +33,7 @@ export const sessions: Session[] = [
serverId: '',
clusterId: 'im-a-cluster-name',
resourceName: 'minikube',
participantModes: ['observer', 'moderator', 'peer'],
},
{
kind: 'ssh',
@ -50,6 +51,7 @@ export const sessions: Session[] = [
resourceName: 'im-a-nodename',
addr: 'd5d6d695-97c5-4bef-b052-0f5c6203d7a1',
clusterId: 'im-a-cluster-name',
participantModes: ['observer', 'moderator'],
},
{
kind: 'desktop',
@ -67,6 +69,7 @@ export const sessions: Session[] = [
resourceName: 'desktop-2',
addr: 'd5d6d695-97c5-4bef-b052-0f5c6203d7a1',
clusterId: 'im-a-cluster-name',
participantModes: ['observer', 'moderator', 'peer'],
},
{
kind: 'db',
@ -84,6 +87,7 @@ export const sessions: Session[] = [
resourceName: 'databse-32',
addr: 'd5d6d695-97c5-4bef-b052-0f5c6203d7a1',
clusterId: 'im-a-cluster-name',
participantModes: ['observer'],
},
{
kind: 'app',
@ -101,5 +105,6 @@ export const sessions: Session[] = [
resourceName: 'grafana',
addr: 'd5d6d695-97c5-4bef-b052-0f5c6203d7a1',
clusterId: 'im-a-cluster-name',
participantModes: ['observer', 'moderator', 'peer'],
},
];

View file

@ -27,9 +27,12 @@ import type {
PrimaryAuthType,
PrivateKeyPolicy,
} from 'shared/services';
import type { SortType } from 'teleport/services/agents';
import type { RecordingType } from 'teleport/services/recordings';
import type { ParticipantMode } from 'teleport/services/session';
const cfg = {
isEnterprise: false,
isCloud: false,
@ -346,8 +349,15 @@ const cfg = {
});
},
getSshSessionRoute({ clusterId, sid }: UrlParams) {
return generatePath(cfg.routes.consoleSession, { clusterId, sid });
getSshSessionRoute({ clusterId, sid }: UrlParams, mode?: ParticipantMode) {
const basePath = generatePath(cfg.routes.consoleSession, {
clusterId,
sid,
});
if (mode) {
return `${basePath}?mode=${mode}`;
}
return basePath;
},
getPasswordTokenUrl(tokenId?: string) {
@ -574,6 +584,7 @@ export interface UrlSshParams {
login?: string;
serverId?: string;
sid?: string;
mode?: ParticipantMode;
clusterId: string;
}

View file

@ -37,6 +37,7 @@ export default function makeSession(json): Session {
cluster_name,
server_addr,
parties,
participantModes,
} = json;
const createdDate = created ? new Date(created) : null;
@ -56,6 +57,7 @@ export default function makeSession(json): Session {
clusterId: cluster_name,
parties: parties ? parties.map(p => makeParticipant(p)) : [],
addr: server_addr ? server_addr.replace(PORT_REGEX, '') : '',
participantModes: participantModes ?? [],
};
}

View file

@ -37,6 +37,8 @@ export interface Session {
// - desktop: is referring to the desktop
// - app: is referring to the app
resourceName: string;
// participantModes are the participant modes that are available to the user listing this session.
participantModes: ParticipantMode[];
}
export type SessionMetadata = {
@ -63,6 +65,8 @@ export type SessionMetadata = {
resourceName: string;
};
export type ParticipantMode = 'observer' | 'moderator' | 'peer';
export type ParticipantList = Record<string, Participant[]>;
export type SessionKind = 'ssh' | 'k8s' | 'db' | 'app' | 'desktop';