mirror of
https://github.com/gravitational/teleport
synced 2024-10-21 09:44:51 +00:00
[Web] Add support for Moderated Sessions in the Web UI (#20782)
This commit is contained in:
parent
b1d62883de
commit
4a90ff4632
|
@ -98,4 +98,5 @@ const session: Session = {
|
|||
clusterId: '',
|
||||
parties: [],
|
||||
addr: '1.1.1.1:1111',
|
||||
participantModes: ['observer', 'moderator', 'peer'],
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'],
|
||||
},
|
||||
];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
Loading…
Reference in a new issue