diff --git a/web/packages/teleport/src/Discover/Discover.tsx b/web/packages/teleport/src/Discover/Discover.tsx index 03af3e41ce68a..96f71d9ad4654 100644 --- a/web/packages/teleport/src/Discover/Discover.tsx +++ b/web/packages/teleport/src/Discover/Discover.tsx @@ -33,6 +33,7 @@ import { SelectResource } from './SelectResource'; import { DownloadScript } from './DownloadScript'; import { LoginTrait } from './LoginTrait'; import { TestConnection } from './TestConnection'; +import { Finished } from './Finished'; import type { AgentKind } from './useDiscover'; import type { AgentStepComponent } from './types'; @@ -42,7 +43,7 @@ export const agentViews: Record = { db: [], desktop: [], kube: [], - node: [SelectResource, DownloadScript, LoginTrait, TestConnection], + node: [SelectResource, DownloadScript, LoginTrait, TestConnection, Finished], }; export default function Container() { @@ -152,6 +153,7 @@ function SideNavAgentConnect({ currentStep }) { 'Configure Resource', 'Configure Role', 'Test Connection', + '', ]; return ( @@ -178,7 +180,7 @@ function SideNavAgentConnect({ currentStep }) { Resource Connection - {agentStepTitles.map((step, index) => { + {agentStepTitles.map((stepTitle, index) => { let className = ''; if (currentStep > index) { className = 'checked'; @@ -186,10 +188,16 @@ function SideNavAgentConnect({ currentStep }) { className = 'active'; } + // All flows will have a finished step that + // does not have a title. + if (!stepTitle) { + return null; + } + return ( - + - {step} + {stepTitle} ); })} diff --git a/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.story.tsx b/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.story.tsx index 6381b9ec1a62c..4e52d6b6a986a 100644 --- a/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.story.tsx +++ b/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.story.tsx @@ -57,4 +57,5 @@ const props: State = { expiry: new Date(), }, regenerateScriptAndRepoll: () => null, + countdownTime: { minutes: 5, seconds: 0 }, }; diff --git a/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.tsx b/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.tsx index 32c1fc2772eba..7c209a3246210 100644 --- a/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.tsx +++ b/web/packages/teleport/src/Discover/DownloadScript/DownloadScript.tsx @@ -30,7 +30,7 @@ import { Header, ActionButtons, TextIcon } from '../Shared'; import { useDownloadScript } from './useDownloadScript'; -import type { State } from './useDownloadScript'; +import type { State, CountdownTime } from './useDownloadScript'; export default function Container(props: AgentStepProps) { const ctx = useDiscoverContext(); @@ -45,6 +45,7 @@ export function DownloadScript({ nextStep, pollState, regenerateScriptAndRepoll, + countdownTime, }: State) { return ( @@ -70,22 +71,29 @@ export function DownloadScript({ /> {pollState === 'polling' && ( - + - Waiting for resource discovery... + {`Waiting for node | ${formatTime( + countdownTime + )} until this script expires`} )} {pollState === 'success' && ( - Successfully discovered resource + Successfully executed )} {pollState === 'error' && ( - Timed out, failed to discover resource.{' '} + Timeout, script expired{' '} theme.colors.link}; @@ -95,7 +103,7 @@ export function DownloadScript({ min-height: auto; `} > - Generate a new script and try again. + Regenerate Script )} @@ -114,6 +122,28 @@ function createBashCommand(tokenId: string) { return `sudo bash -c "$(curl -fsSL ${cfg.getNodeScriptUrl(tokenId)})"`; } +function formatTime({ minutes, seconds }: CountdownTime) { + let formattedSeconds = seconds.toString(); + if (seconds < 10) { + formattedSeconds = `0${seconds}`; + } + + let formattedMinutes = minutes.toString(); + if (minutes < 10) { + formattedMinutes = `0${minutes}`; + } + + let timeNotation = 'minute'; + if (!minutes && seconds >= 0) { + timeNotation = 'seconds'; + } + if (minutes) { + timeNotation = 'minutes'; + } + + return `${formattedMinutes}:${formattedSeconds} ${timeNotation}`; +} + const ScriptBox = styled(Box)` max-width: 800px; background-color: rgba(255, 255, 255, 0.05); diff --git a/web/packages/teleport/src/Discover/DownloadScript/__snapshots__/DownloadScript.story.test.tsx.snap b/web/packages/teleport/src/Discover/DownloadScript/__snapshots__/DownloadScript.story.test.tsx.snap index f98c4bbbf3c89..a3e31190722d3 100644 --- a/web/packages/teleport/src/Discover/DownloadScript/__snapshots__/DownloadScript.story.test.tsx.snap +++ b/web/packages/teleport/src/Discover/DownloadScript/__snapshots__/DownloadScript.story.test.tsx.snap @@ -137,6 +137,7 @@ exports[`polling error state 1`] = ` min-height: 32px; font-size: 12px; padding: 0px 24px; + margin-left: 8px; } .c11:active { @@ -372,13 +373,13 @@ exports[`polling error state 1`] = ` class="c10 icon icon-warning " color="danger" /> - Timed out, failed to discover resource. + Timeout, script expired @@ -600,27 +601,28 @@ exports[`polling state 1`] = ` display: flex; } +.c3 { + box-sizing: border-box; + padding: 16px; + border-radius: 8px; + max-width: 800px; + background-color: rgba(255,255,255,0.05); + border: 2px solid #2F3659; +} + .c9 { overflow: hidden; text-overflow: ellipsis; margin: 0px; display: flex; align-items: center; + white-space: pre; } .c9 .icon { margin-right: 8px; } -.c3 { - box-sizing: border-box; - padding: 16px; - border-radius: 8px; - max-width: 800px; - background-color: rgba(255,255,255,0.05); - border: 2px solid #2F3659; -} -
@@ -674,7 +676,7 @@ exports[`polling state 1`] = ` color="light" font-size="4" /> - Waiting for resource discovery... + Waiting for node | 05:00 minutes until this script expires
- Successfully discovered resource + Successfully executed
(); const [pollState, setPollState] = useState('polling'); + // TODO (lisa) extract count down logic into it's own component. + const [countdownTime, setCountdownTime] = useState({ + minutes: 5, + seconds: 0, + }); + // Responsible for initial join token fetch. useEffect(() => { fetchJoinToken(); @@ -48,7 +60,11 @@ export function useDownloadScript({ ctx, props }: Props) { const abortController = new AbortController(); const abortSignal = abortController.signal; let timeoutId; - let intervalId; + let pollingIntervalId; + let countdownIntervalId; + + // countdownEndDate takes current date and adds 5 minutes to it. + let countdownEndDate = addMinutes(new Date(), FIVE_MINUTES); // inFlightReq is a flag to prevent another fetch request when a // previous fetch request is still in progress. May happen when a @@ -57,8 +73,10 @@ export function useDownloadScript({ ctx, props }: Props) { let inFlightReq; function cleanUp() { - clearInterval(intervalId); + clearInterval(pollingIntervalId); + clearInterval(countdownIntervalId); clearTimeout(timeoutId); + setCountdownTime({ minutes: 5, seconds: 0 }); // Cancel any in flight request. abortController.abort(); } @@ -94,20 +112,37 @@ export function useDownloadScript({ ctx, props }: Props) { }); } - // Start the poller to discover the resource just added. - intervalId = setInterval( - () => fetchNodeMatchingRefResourceId(), - THREE_SECONDS_IN_MS - ); + function updateCountdown() { + const start = new Date(); + const end = countdownEndDate; + const duration = intervalToDuration({ start, end }); + + if (differenceInMilliseconds(end, start) <= 0) { + setPollState('error'); + cleanUp(); + return; + } + + setCountdownTime({ + minutes: duration.minutes, + seconds: duration.seconds, + }); + } - // Set a timeout in case polling continuosly produces + // Set a countdown in case polling continuosly produces // no results. Which means there is either a network error, // script is ran unsuccessfully, script has not been ran, // or resource cannot connect to cluster. - timeoutId = setTimeout(() => { - setPollState('error'); - cleanUp(); - }, FIVE_MINUTES_IN_MS); + countdownIntervalId = setInterval( + () => updateCountdown(), + ONE_SECOND_IN_MS + ); + + // Start the poller to discover the resource just added. + pollingIntervalId = setInterval( + () => fetchNodeMatchingRefResourceId(), + THREE_SECONDS_IN_MS + ); return () => { cleanUp(); @@ -142,6 +177,7 @@ export function useDownloadScript({ ctx, props }: Props) { nextStep: props.nextStep, pollState, regenerateScriptAndRepoll, + countdownTime, }; } @@ -152,4 +188,9 @@ type Props = { type PollState = 'polling' | 'success' | 'error'; +export type CountdownTime = { + minutes: number; + seconds: number; +}; + export type State = ReturnType; diff --git a/web/packages/teleport/src/Discover/Finished/Finished.story.tsx b/web/packages/teleport/src/Discover/Finished/Finished.story.tsx new file mode 100644 index 0000000000000..2beafda5fd8c0 --- /dev/null +++ b/web/packages/teleport/src/Discover/Finished/Finished.story.tsx @@ -0,0 +1,25 @@ +/** + * Copyright 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. + * 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 { Finished } from './Finished'; + +export default { + title: 'Teleport/Discover/Finished', +}; + +export const Loaded = () => ; diff --git a/web/packages/teleport/src/Discover/Finished/Finished.tsx b/web/packages/teleport/src/Discover/Finished/Finished.tsx new file mode 100644 index 0000000000000..60d6574ac061c --- /dev/null +++ b/web/packages/teleport/src/Discover/Finished/Finished.tsx @@ -0,0 +1,56 @@ +/** + * Copyright 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. + * 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 { ButtonPrimary, Text, Flex, ButtonText, Image } from 'design'; + +import cfg from 'teleport/config'; +import history from 'teleport/services/history'; + +import celebratePamPng from './celebrate-pam.png'; + +export function Finished() { + return ( + + + + Resource Successfully Connected + + history.push(cfg.routes.root, true)} + > + Go to Access Provider + + history.reload()} + > + Add Another Resource + + + ); +} diff --git a/web/packages/teleport/src/Discover/Finished/celebrate-pam.png b/web/packages/teleport/src/Discover/Finished/celebrate-pam.png new file mode 100644 index 0000000000000..a7f4bec6c68a8 Binary files /dev/null and b/web/packages/teleport/src/Discover/Finished/celebrate-pam.png differ diff --git a/web/packages/teleport/src/Discover/Finished/index.ts b/web/packages/teleport/src/Discover/Finished/index.ts new file mode 100644 index 0000000000000..fbabc88789bdf --- /dev/null +++ b/web/packages/teleport/src/Discover/Finished/index.ts @@ -0,0 +1,16 @@ +/** + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { Finished } from './Finished'; diff --git a/web/packages/teleport/src/Discover/LoginTrait/useLoginTrait.ts b/web/packages/teleport/src/Discover/LoginTrait/useLoginTrait.ts index ddc8eb4e4f31e..7e0be4bc82185 100644 --- a/web/packages/teleport/src/Discover/LoginTrait/useLoginTrait.ts +++ b/web/packages/teleport/src/Discover/LoginTrait/useLoginTrait.ts @@ -48,7 +48,7 @@ export function useLoginTrait({ ctx, props }: Props) { ); }, []); - function nextStep(logins: string[]) { + async function nextStep(logins: string[]) { // Currently, fetching user for user traits does not // include the statically defined OS usernames, so // we combine it manually in memory with the logins @@ -63,13 +63,17 @@ export function useLoginTrait({ ctx, props }: Props) { // Update the dynamic logins for the user in backend. setAttempt({ status: 'processing' }); - ctx.userService - .updateUser({ + try { + await ctx.userService.updateUser({ ...user, traits: { ...user.traits, logins }, - }) - .then(props.nextStep) - .catch(handleError); + }); + + await ctx.userService.applyUserTraits(); + props.nextStep(); + } catch (err) { + handleError(err); + } } function addLogin(newLogin: string) { diff --git a/web/packages/teleport/src/Discover/Shared.tsx b/web/packages/teleport/src/Discover/Shared.tsx index 11abb3b103ac9..5137c9a08db8b 100644 --- a/web/packages/teleport/src/Discover/Shared.tsx +++ b/web/packages/teleport/src/Discover/Shared.tsx @@ -37,9 +37,11 @@ export const Header: React.FC = ({ children }) => ( export const ActionButtons = ({ onProceed, disableProceed, + lastStep, }: { onProceed?(): void; disableProceed?: boolean; + lastStep?: boolean; }) => { const [confirmExit, setConfirmExit] = React.useState(false); return ( @@ -51,7 +53,7 @@ export const ActionButtons = ({ mr={3} disabled={disableProceed} > - Proceed + {lastStep ? 'Finish' : 'Proceed'} )} ( ); -export const LoadedWithDiagnosisFailure = () => ( - -); +export const LoadedWithDiagnosisFailure = () => { + const diagnosisWithErr = { + ...mockDiagnosis, + success: false, + traces: [ + ...mockDiagnosis.traces, + { + id: '', + traceType: 'some trace type', + status: 'failed', + details: 'Some failed detail.', + error: 'ssh: handshake failed: EOF', + } as ConnectionDiagnosticTrace, + ], + }; + return ; +}; export const Failed = () => ( null, runConnectionDiagnostic: () => null, + nextStep: () => null, diagnosis: null, }; diff --git a/web/packages/teleport/src/Discover/TestConnection/TestConnection.tsx b/web/packages/teleport/src/Discover/TestConnection/TestConnection.tsx index 7eafb3e21b41a..ecec901a2b311 100644 --- a/web/packages/teleport/src/Discover/TestConnection/TestConnection.tsx +++ b/web/packages/teleport/src/Discover/TestConnection/TestConnection.tsx @@ -16,7 +16,7 @@ import React, { useState } from 'react'; import styled from 'styled-components'; -import { ButtonPrimary, Text, Box, LabelInput, Flex } from 'design'; +import { ButtonOutlined, Text, Box, LabelInput, Flex } from 'design'; import * as Icons from 'design/Icon'; import Select from 'shared/components/Select'; @@ -41,6 +41,7 @@ export function TestConnection({ logins, runConnectionDiagnostic, diagnosis, + nextStep, }: State) { const [usernameOpts] = useState(() => logins.map(l => ({ value: l, label: l })) @@ -87,7 +88,7 @@ export function TestConnection({ Step 1 - + Select OS Username