diff --git a/web/packages/design/src/DraggableList/DraggableList.tsx b/web/packages/design/src/DraggableList/DraggableList.tsx new file mode 100644 index 0000000000000..745795e30b40d --- /dev/null +++ b/web/packages/design/src/DraggableList/DraggableList.tsx @@ -0,0 +1,172 @@ +/** + * 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, { + Children, + PropsWithChildren, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import styled from 'styled-components'; + +import { animated, to, useSprings } from '@react-spring/web'; +import { useGesture } from '@use-gesture/react'; +import { ReactDOMAttributes } from '@use-gesture/react/dist/declarations/src/types'; + +const Container = styled.div` + position: relative; +`; + +interface DraggableListProps { + onOrderChange: (newOrder: number[]) => void; +} + +export function DraggableList(props: PropsWithChildren) { + const children = Children.toArray(props.children); + + const [childrenHeight, setChildrenHeight] = useState(0); + + const animatedDiv = useRef(null); + const order = useRef(children.map((_, index) => index)); + + function createPropsGetter( + orderList: number[] = order.current, + down?: boolean, + originalIndex?: number, + newHeight?: number + ) { + return function getProps(index: number) { + if (down && index === originalIndex) { + return { + height: newHeight, + width: 0, + zIndex: 1, + immediate: (key: string) => + key === 'active' || key === 'height' || key === 'zIndex', + }; + } + + return { + height: childrenHeight * orderList.indexOf(index), + width: 0, + zIndex: 0, + immediate: false, + }; + }; + } + + const [springs, api] = useSprings(children.length, createPropsGetter()); + + const bind = useGesture({ + onDrag: state => { + const [, offset] = state.movement; + if (offset === 0) { + return; + } + + const [originalIndex] = state.args; + + const curIndex = order.current.indexOf(originalIndex); + const newHeight = childrenHeight * curIndex + offset; + const nextIndex = clamp( + Math.round(newHeight / childrenHeight), + 0, + children.length - 1 + ); + const newOrder = swap(order.current, curIndex, nextIndex); + + api.start( + createPropsGetter(newOrder, state.down, originalIndex, newHeight) + ); + if (!state.down) { + order.current = newOrder; + } + }, + onDragEnd: () => { + api.start(createPropsGetter()); + props.onOrderChange(order.current); + }, + }) as unknown as (...args: any[]) => ReactDOMAttributes; // useGesture typings are wrong https://github.com/pmndrs/use-gesture/issues/362 + + useEffect(() => { + order.current = children.map((_, index) => index); + + api.start(createPropsGetter()); + }, [children.length]); + + useLayoutEffect(() => { + if (!animatedDiv.current) { + return; + } + + const height = animatedDiv.current.scrollHeight; + if (childrenHeight !== height) { + setChildrenHeight(height); + + return; + } + + api.start(createPropsGetter()); + }, [api, childrenHeight, children.length]); + + return ( + e.stopPropagation()} + > + {springs.map((spring, index) => { + const { width, height, zIndex } = spring; + + return ( + `translate3d(${x}px, ${y}px, 0)` + ), + touchAction: 'none', + }} + > + {children[index]} + + ); + })} + + ); +} + +function clamp(pos: number, low: number, high: number) { + const mid = Math.max(pos, low); + + return Math.min(mid, high); +} + +function swap(arr: T[], a: number, b: number): T[] { + const copy = [...arr]; + const [index] = copy.splice(a, 1); + + copy.splice(b, 0, index); + + return copy; +} diff --git a/web/packages/design/src/DraggableList/index.ts b/web/packages/design/src/DraggableList/index.ts new file mode 100644 index 0000000000000..d502f2f0f781c --- /dev/null +++ b/web/packages/design/src/DraggableList/index.ts @@ -0,0 +1,17 @@ +/** + * 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. + */ + +export { DraggableList } from './DraggableList'; diff --git a/web/packages/design/src/SVGIcon/ConversationList.tsx b/web/packages/design/src/SVGIcon/ConversationList.tsx new file mode 100644 index 0000000000000..f3519bec84857 --- /dev/null +++ b/web/packages/design/src/SVGIcon/ConversationList.tsx @@ -0,0 +1,29 @@ +/* +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 { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function ConversationListIcon({ size = 24, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/Display.tsx b/web/packages/design/src/SVGIcon/Display.tsx new file mode 100644 index 0000000000000..82596fae137ce --- /dev/null +++ b/web/packages/design/src/SVGIcon/Display.tsx @@ -0,0 +1,29 @@ +/* +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 { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function DisplayIcon({ size = 32, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/Error.tsx b/web/packages/design/src/SVGIcon/Error.tsx new file mode 100644 index 0000000000000..d17d0391e51e6 --- /dev/null +++ b/web/packages/design/src/SVGIcon/Error.tsx @@ -0,0 +1,29 @@ +/* +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 { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function ErrorIcon({ size = 24, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/Settings.tsx b/web/packages/design/src/SVGIcon/Settings.tsx new file mode 100644 index 0000000000000..4231971cffaa9 --- /dev/null +++ b/web/packages/design/src/SVGIcon/Settings.tsx @@ -0,0 +1,29 @@ +/* +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 { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function SettingsIcon({ size = 32, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/Terminal.tsx b/web/packages/design/src/SVGIcon/Terminal.tsx new file mode 100644 index 0000000000000..47b629c89e336 --- /dev/null +++ b/web/packages/design/src/SVGIcon/Terminal.tsx @@ -0,0 +1,29 @@ +/* +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 { SVGIcon } from './SVGIcon'; + +import type { SVGIconProps } from './common'; + +export function TerminalIcon({ size = 32, fill }: SVGIconProps) { + return ( + + + + ); +} diff --git a/web/packages/design/src/SVGIcon/index.ts b/web/packages/design/src/SVGIcon/index.ts index f3be132b9c496..7f20066ee38a6 100644 --- a/web/packages/design/src/SVGIcon/index.ts +++ b/web/packages/design/src/SVGIcon/index.ts @@ -27,11 +27,14 @@ export { CheckIcon } from './Check'; export { ChevronDownIcon } from './ChevronDown'; export { ChevronRightIcon } from './ChevronRight'; export { CloseIcon } from './Close'; +export { ConversationListIcon } from './ConversationList'; export { DatabasesIcon } from './Databases'; export { DesktopsIcon } from './Desktops'; export { DevicesIcon } from './Devices'; +export { DisplayIcon } from './Display'; export { DownloadsIcon } from './Downloads'; export { EditIcon } from './Edit'; +export { ErrorIcon } from './Error'; export { ExpandIcon } from './Expand'; export { ExternalLinkIcon } from './ExternalLink'; export { IntegrationsIcon } from './Integrations'; @@ -50,8 +53,10 @@ export { SearchIcon } from './Search'; export { ServerIcon } from './Server'; export { ServersIcon } from './Servers'; export { SessionRecordingsIcon } from './SessionRecordings'; +export { SettingsIcon } from './Settings'; export { SidebarIcon } from './Sidebar'; export { SupportIcon } from './Support'; +export { TerminalIcon } from './Terminal'; export { TrustedClustersIcon } from './TrustedClusters'; export { UpgradeIcon } from './Upgrade'; export { UserIcon } from './User'; diff --git a/web/packages/shared/package.json b/web/packages/shared/package.json index 5d7e63be09a5e..b454c20a89cd9 100644 --- a/web/packages/shared/package.json +++ b/web/packages/shared/package.json @@ -10,6 +10,8 @@ "directory": "packages/shared" }, "dependencies": { + "@react-spring/web": "^9.7.2", + "@use-gesture/react": "^10.2.27", "ace-builds": "1.4.6", "create-react-class": "^15.6.3", "cross-env": "5.0.5", diff --git a/web/packages/teleport/src/Assist/Assist.tsx b/web/packages/teleport/src/Assist/Assist.tsx index 8acd171585da0..88a517b03dd02 100644 --- a/web/packages/teleport/src/Assist/Assist.tsx +++ b/web/packages/teleport/src/Assist/Assist.tsx @@ -14,17 +14,21 @@ * limitations under the License. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import styled, { keyframes } from 'styled-components'; -import { useLocalStorage } from 'shared/hooks/useLocalStorage'; - import { Header } from 'teleport/Assist/Header'; import { ConversationHistory } from 'teleport/Assist/ConversationHistory'; -import { AssistContextProvider } from 'teleport/Assist/context/AssistContext'; +import { + AssistContextProvider, + useAssist, +} from 'teleport/Assist/context/AssistContext'; import { ConversationList } from 'teleport/Assist/ConversationList'; -import { KeysEnum } from 'teleport/services/localStorage'; import { useLayout } from 'teleport/Main/LayoutContext'; +import { ViewMode } from 'teleport/Assist/types'; +import { Settings } from 'teleport/Assist/Settings'; +import { ErrorBanner, ErrorList } from 'teleport/Assist/ErrorBanner'; +import { useUser } from 'teleport/User/UserContext'; interface AssistProps { onClose: () => void; @@ -50,10 +54,9 @@ const slideIn = keyframes` } `; -function variables(props: { viewMode: AssistViewMode }) { +function variables(props: { viewMode: ViewMode }) { switch (props.viewMode) { - case AssistViewMode.Collapsed: - case AssistViewMode.CollapsedSidebarVisible: + case ViewMode.Popup: return { '--assist-gutter': '20px', '--assist-border-radius': '15px', @@ -67,8 +70,8 @@ function variables(props: { viewMode: AssistViewMode }) { '--assist-bottom-padding': '5px', }; - case AssistViewMode.Expanded: - case AssistViewMode.ExpandedSidebarVisible: + case ViewMode.PopupExpanded: + case ViewMode.PopupExpandedSidebarVisible: return { '--assist-gutter': '20px', '--assist-border-radius': '15px', @@ -82,8 +85,7 @@ function variables(props: { viewMode: AssistViewMode }) { '--assist-bottom-padding': '5px', }; - case AssistViewMode.Docked: - case AssistViewMode.DockedSidebarVisible: + case ViewMode.Docked: return { '--assist-gutter': '0', '--assist-border-radius': '0', @@ -99,9 +101,23 @@ function variables(props: { viewMode: AssistViewMode }) { } } -function sidebarVariables(props: { viewMode: AssistViewMode }) { +function sidebarVariables(props: { + viewMode: ViewMode; + sidebarVisible: boolean; +}) { switch (props.viewMode) { - case AssistViewMode.Collapsed: + case ViewMode.Popup: + if (props.sidebarVisible) { + return { + '--conversation-width': '550px', + '--conversation-list-width': '550px', + '--conversation-list-margin': '0', + '--command-input-width': '400px', + '--conversation-list-display': 'flex', + '--conversation-list-position': 'absolute', + }; + } + return { '--conversation-width': '555px', '--conversation-list-width': '550px', @@ -112,17 +128,19 @@ function sidebarVariables(props: { viewMode: AssistViewMode }) { '--conversation-list-position': 'absolute', }; - case AssistViewMode.CollapsedSidebarVisible: - return { - '--conversation-width': '550px', - '--conversation-list-width': '550px', - '--conversation-list-margin': '0', - '--command-input-width': '400px', - '--conversation-list-display': 'flex', - '--conversation-list-position': 'absolute', - }; + case ViewMode.PopupExpanded: + case ViewMode.PopupExpandedSidebarVisible: + if (props.sidebarVisible) { + return { + '--conversation-list-margin': '0', + '--conversation-width': '900px', + '--conversation-list-width': '250px', + '--command-input-width': '600px', + '--conversation-list-display': 'flex', + '--conversation-list-position': 'static', + }; + } - case AssistViewMode.Expanded: return { '--conversation-width': '1100px', '--conversation-list-width': '250px', @@ -133,17 +151,18 @@ function sidebarVariables(props: { viewMode: AssistViewMode }) { '--conversation-list-position': 'absolute', }; - case AssistViewMode.ExpandedSidebarVisible: - return { - '--conversation-list-margin': '0', - '--conversation-width': '850px', - '--conversation-list-width': '250px', - '--command-input-width': '600px', - '--conversation-list-display': 'flex', - '--conversation-list-position': 'static', - }; + case ViewMode.Docked: + if (props.sidebarVisible) { + return { + '--conversation-width': '520px', + '--conversation-list-width': '520px', + '--conversation-list-margin': '0', + '--command-input-width': '380px', + '--conversation-list-display': 'flex', + '--conversation-list-position': 'absolute', + }; + } - case AssistViewMode.Docked: return { '--conversation-width': '525px', '--conversation-list-width': '520px', @@ -153,16 +172,6 @@ function sidebarVariables(props: { viewMode: AssistViewMode }) { '--conversation-list-display': 'none', '--conversation-list-position': 'absolute', }; - - case AssistViewMode.DockedSidebarVisible: - return { - '--conversation-width': '520px', - '--conversation-list-width': '520px', - '--conversation-list-margin': '0', - '--command-input-width': '380px', - '--conversation-list-display': 'flex', - '--conversation-list-position': 'absolute', - }; } } @@ -217,148 +226,161 @@ const AssistConversation = styled.div` height: 100%; `; -const AssistContent = styled.div` +const Content = styled.div` flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; + position: relative; `; -export enum AssistViewMode { - Collapsed = 'collapsed', - CollapsedSidebarVisible = 'collapsed-sidebar-visible', - Expanded = 'expanded', - ExpandedSidebarVisible = 'expanded-sidebar-visible', - Docked = 'docked', - DockedSidebarVisible = 'docked-sidebar-visible', -} - -function isDocked(viewMode: AssistViewMode) { +export function Assist(props: AssistProps) { return ( - viewMode === AssistViewMode.Docked || - viewMode === AssistViewMode.DockedSidebarVisible + + + ); } -function isSidebarVisible(viewMode: AssistViewMode) { - return ( - viewMode === AssistViewMode.CollapsedSidebarVisible || - viewMode === AssistViewMode.ExpandedSidebarVisible || - viewMode === AssistViewMode.DockedSidebarVisible - ); +let errorIndex = 0; + +interface AssistError { + message: string; + index: number; } -export function Assist(props: AssistProps) { - const [viewMode, setViewMode] = useLocalStorage( - KeysEnum.ASSIST_VIEW_MODE, - AssistViewMode.Collapsed +function getInitialErrors(conversations: { error?: string }) { + const errors: AssistError[] = []; + + if (conversations.error) { + errors.push({ + message: conversations.error, + index: errorIndex++, + }); + } + + return errors; +} + +function AssistContent(props: AssistProps) { + const { preferences } = useUser(); + const { conversations, sidebarVisible, toggleSidebar } = useAssist(); + + const [errors, setErrors] = useState<{ message: string; index: number }[]>( + () => getInitialErrors(conversations) ); + const [settingsOpen, setSettingsOpen] = useState(false); + const [debugMenuEnabled, setDebugMenuEnabled] = useState(false); const { hasDockedElement, setHasDockedElement } = useLayout(); useEffect(() => { - if (!hasDockedElement && isDocked(viewMode)) { + if (!hasDockedElement && preferences.assist.viewMode === ViewMode.Docked) { setHasDockedElement(true); } - if (hasDockedElement && !isDocked(viewMode)) { + if (hasDockedElement && preferences.assist.viewMode !== ViewMode.Docked) { setHasDockedElement(false); } - }, [hasDockedElement, viewMode]); + }, [hasDockedElement, preferences.assist.viewMode]); function handleClick(e: React.MouseEvent) { e.stopPropagation(); } function handleConversationSelect() { - if (viewMode === AssistViewMode.CollapsedSidebarVisible) { - setViewMode(AssistViewMode.Collapsed); + if (!sidebarVisible) { + return; } - if (viewMode === AssistViewMode.DockedSidebarVisible) { - setViewMode(AssistViewMode.Docked); + if ( + preferences.assist.viewMode === ViewMode.Popup || + preferences.assist.viewMode === ViewMode.Docked + ) { + toggleSidebar(false); } } - function handleExpand() { - switch (viewMode) { - case AssistViewMode.Collapsed: - setViewMode(AssistViewMode.Expanded); - - break; - - case AssistViewMode.CollapsedSidebarVisible: - setViewMode(AssistViewMode.ExpandedSidebarVisible); - - break; - - case AssistViewMode.Expanded: - case AssistViewMode.ExpandedSidebarVisible: - setViewMode(AssistViewMode.Collapsed); + function handleClose() { + props.onClose(); + setHasDockedElement(false); - break; + if ( + sidebarVisible && + preferences.assist.viewMode !== ViewMode.PopupExpandedSidebarVisible + ) { + toggleSidebar(false); } } - function handleDocking() { - switch (viewMode) { - case AssistViewMode.Collapsed: - case AssistViewMode.CollapsedSidebarVisible: - case AssistViewMode.Expanded: - case AssistViewMode.ExpandedSidebarVisible: - setViewMode(AssistViewMode.Docked); - - break; - - case AssistViewMode.Docked: - case AssistViewMode.DockedSidebarVisible: - setViewMode(AssistViewMode.Collapsed); + function handleToggleSidebar() { + toggleSidebar(!sidebarVisible); + } - break; + function handleDebugMenuToggle(enabled: boolean) { + if (process.env.NODE_ENV !== 'development') { + throw new Error('Debug menu is only available in development mode'); } - } - function handleViewModeChange(viewMode: AssistViewMode) { - setViewMode(viewMode); + setDebugMenuEnabled(enabled); } - function handleClose() { - props.onClose(); - setHasDockedElement(false); - - if (viewMode === AssistViewMode.CollapsedSidebarVisible) { - setViewMode(AssistViewMode.Collapsed); - } + function handleError(message: string) { + setErrors([...errors, { message, index: errorIndex++ }]); + } - if (viewMode === AssistViewMode.DockedSidebarVisible) { - setViewMode(AssistViewMode.Docked); - } + function removeError(index: number) { + setErrors(errors.filter(error => error.index !== index)); } + const errorList = errors.map(error => ( + removeError(error.index)}> + {error.message} + + )); + return ( - - - -
- - {isSidebarVisible(viewMode) && ( - - )} - - - - - - - + + {settingsOpen && ( + setSettingsOpen(false)} + debugMenuEnabled={debugMenuEnabled} + onDebugMenuToggle={handleDebugMenuToggle} + /> + )} + + +
setSettingsOpen(true)} + onToggleSidebar={handleToggleSidebar} + sidebarVisible={sidebarVisible} + viewMode={preferences.assist.viewMode} + onError={handleError} + /> + + {errorList} + + + {sidebarVisible && ( + + )} + + + + + + ); } diff --git a/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx b/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx index a55c9cd9e8465..ed062eae21cfe 100644 --- a/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx +++ b/web/packages/teleport/src/Assist/Conversation/ExecuteRemoteCommandEntry.tsx @@ -24,6 +24,7 @@ import { ButtonPrimary, ButtonSecondary } from 'design'; import { useAssist } from 'teleport/Assist/context/AssistContext'; import { getLoginsForQuery } from 'teleport/Assist/service'; import useStickyClusterId from 'teleport/useStickyClusterId'; +import { useUser } from 'teleport/User/UserContext'; interface ExecuteRemoteCommandEntryProps { command: string; @@ -84,6 +85,7 @@ const InfoText = styled.span` export function ExecuteRemoteCommandEntry( props: ExecuteRemoteCommandEntryProps ) { + const { preferences } = useUser(); const { executeCommand } = useAssist(); const [hasRan, setHasRan] = useState(false); @@ -105,7 +107,12 @@ export function ExecuteRemoteCommandEntry( try { const logins = await getLoginsForQuery(query, clusterId); - if (!selectedLogin || !logins.includes(selectedLogin)) { + const preferredLogin = logins.find(login => + preferences.assist.preferredLogins.includes(login) + ); + if (preferredLogin) { + setSelectedLogin(preferredLogin); + } else if (!selectedLogin || !logins.includes(selectedLogin)) { setSelectedLogin(logins[0]); } diff --git a/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx b/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx index 345234311f4aa..829e47a8724b8 100644 --- a/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx +++ b/web/packages/teleport/src/Assist/ConversationHistory/ConversationHistory.tsx @@ -17,16 +17,15 @@ import React, { useState } from 'react'; import styled from 'styled-components'; -import { ButtonPrimary } from 'design'; - import { useAssist } from 'teleport/Assist/context/AssistContext'; import { ConversationHistoryItem } from 'teleport/Assist/ConversationHistory/ConversationHistoryItem'; -import { AssistViewMode } from 'teleport/Assist/Assist'; import { DeleteConversationDialog } from 'teleport/Assist/ConversationHistory/DeleteConversationDialog'; +import { ViewMode } from 'teleport/Assist/types'; interface ConversationHistoryProps { onConversationSelect: (id: string) => void; - viewMode: AssistViewMode; + viewMode: ViewMode; + onError: (message: string) => void; } const Container = styled.ul.attrs({ 'data-scrollbar': 'default' })` @@ -40,7 +39,7 @@ const Container = styled.ul.attrs({ 'data-scrollbar': 'default' })` width: var(--conversation-list-width); margin: 0; position: var(--conversation-list-position); - top: var(--assist-header-height); + top: 0; bottom: 0; background: ${p => p.theme.colors.levels.popout}; z-index: 999; @@ -57,38 +56,17 @@ const List = styled.ul.attrs({ 'data-scrollbar': 'default' })` overflow-y: auto; `; -const NewConversationButton = styled.li` - margin: 10px 10px 0; - - button { - width: 100%; - } -`; - -const ErrorMessage = styled.div` - background: ${props => props.theme.colors.error.main}; - color: white; - border-radius: 5px; - margin-bottom: 10px; - padding: 5px 10px; -`; - -function isExpanded(viewMode: AssistViewMode) { +function isExpanded(viewMode: ViewMode) { return ( - viewMode === AssistViewMode.Expanded || - viewMode === AssistViewMode.ExpandedSidebarVisible + viewMode === ViewMode.PopupExpanded || + viewMode === ViewMode.PopupExpandedSidebarVisible ); } export function ConversationHistory(props: ConversationHistoryProps) { - const { - conversations, - createConversation, - deleteConversation, - setSelectedConversationId, - } = useAssist(); - - const [errorMessage, setErrorMessage] = useState(null); + const { conversations, deleteConversation, setSelectedConversationId } = + useAssist(); + const [deleting, setDeleting] = useState(false); const [deleteErrorMessage, setDeleteErrorMessage] = useState(null); @@ -101,7 +79,7 @@ export function ConversationHistory(props: ConversationHistoryProps) { await setSelectedConversationId(id); } catch (err) { - setErrorMessage(err.message); + props.onError('Failed to load the conversation.'); } } @@ -120,16 +98,6 @@ export function ConversationHistory(props: ConversationHistoryProps) { setDeleting(false); } - async function handleCreateNewConversation() { - try { - const id = await createConversation(); - - props.onConversationSelect(id); - } catch (err) { - setErrorMessage(err.message); - } - } - const conversationToDelete = conversations.data.find( conversation => conversation.id === conversationIdToDelete ); @@ -159,14 +127,6 @@ export function ConversationHistory(props: ConversationHistoryProps) { /> )} - - {errorMessage && {errorMessage}} - - handleCreateNewConversation()}> - New conversation - - - {items} ); diff --git a/web/packages/teleport/src/Assist/ConversationList/ConversationList.tsx b/web/packages/teleport/src/Assist/ConversationList/ConversationList.tsx index 3930b5aa4ac7a..314ca4cece4a0 100644 --- a/web/packages/teleport/src/Assist/ConversationList/ConversationList.tsx +++ b/web/packages/teleport/src/Assist/ConversationList/ConversationList.tsx @@ -19,12 +19,12 @@ import styled from 'styled-components'; import { Conversation } from 'teleport/Assist/Conversation'; import { useAssist } from 'teleport/Assist/context/AssistContext'; -import { AssistViewMode } from 'teleport/Assist/Assist'; import { MessageBox } from 'teleport/Assist/MessageBox'; import { LandingPage } from 'teleport/Assist/LandingPage'; +import { ViewMode } from 'teleport/Assist/types'; interface ConversationListProps { - viewMode: AssistViewMode; + viewMode: ViewMode; } const Container = styled.div.attrs({ 'data-scrollbar': 'default' })` diff --git a/web/packages/teleport/src/Assist/ErrorBanner/ErrorBanner.tsx b/web/packages/teleport/src/Assist/ErrorBanner/ErrorBanner.tsx new file mode 100644 index 0000000000000..b7ff443bb1c62 --- /dev/null +++ b/web/packages/teleport/src/Assist/ErrorBanner/ErrorBanner.tsx @@ -0,0 +1,110 @@ +/** + * 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, { PropsWithChildren, useState } from 'react'; +import styled, { keyframes } from 'styled-components'; + +import { CloseIcon, ErrorIcon } from 'design/SVGIcon'; + +interface ErrorBannerProps { + onDismiss: () => void; +} + +const appear = keyframes` + to { + opacity: 1; + } +`; + +const disappear = keyframes` + to { + opacity: 0; + } +`; + +const Container = styled.div` + position: relative; + border-top: 2px solid ${props => props.theme.colors.error.main}; + border-bottom: 2px solid ${props => props.theme.colors.error.main}; + padding: 5px 45px 5px 10px; + display: flex; + align-items: center; + color: ${props => props.theme.colors.error.active}; + opacity: ${p => (p.hiding ? 1 : 0)}; + animation: ${p => (p.hiding ? disappear : appear)} 0.5s ease-in-out forwards; + + & + & { + border-top: none; + } +`; + +const ErrorIconContainer = styled.div` + display: flex; + align-items: center; + + svg { + margin-right: 10px; + } + + svg path { + fill: ${props => props.theme.colors.error.active}; + } +`; + +const CloseButton = styled.div` + position: absolute; + top: 50%; + right: 5px; + transform: translateY(-50%); + cursor: pointer; + padding: 5px; + border-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + z-index: 999; + + &:hover { + background: ${p => p.theme.colors.spotBackground[0]}; + } + + svg path { + stroke: ${p => p.theme.colors.text.main}; + } +`; + +export function ErrorBanner(props: PropsWithChildren) { + const [hiding, setHiding] = useState(false); + + function handleClose() { + setHiding(true); + setTimeout(() => props.onDismiss(), 500); + } + + return ( + + + + + + {props.children} + + + + + + ); +} diff --git a/web/packages/teleport/src/Assist/ErrorBanner/ErrorList.tsx b/web/packages/teleport/src/Assist/ErrorBanner/ErrorList.tsx new file mode 100644 index 0000000000000..84a53391194f4 --- /dev/null +++ b/web/packages/teleport/src/Assist/ErrorBanner/ErrorList.tsx @@ -0,0 +1,23 @@ +/** + * 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 styled from 'styled-components'; + +export const ErrorList = styled.div` + transition: height 0.3s ease-in-out; + margin-top: -1px; // cover up the header's bottom border + height: ${p => 2 + p.children.length * 36}px; +`; diff --git a/web/packages/teleport/src/Assist/ErrorBanner/index.ts b/web/packages/teleport/src/Assist/ErrorBanner/index.ts new file mode 100644 index 0000000000000..687dccfb80709 --- /dev/null +++ b/web/packages/teleport/src/Assist/ErrorBanner/index.ts @@ -0,0 +1,18 @@ +/** + * 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. + */ + +export { ErrorBanner } from './ErrorBanner'; +export { ErrorList } from './ErrorList'; diff --git a/web/packages/teleport/src/Assist/Header/Header.tsx b/web/packages/teleport/src/Assist/Header/Header.tsx index 95289dc7e7940..25da1180b1509 100644 --- a/web/packages/teleport/src/Assist/Header/Header.tsx +++ b/web/packages/teleport/src/Assist/Header/Header.tsx @@ -18,22 +18,24 @@ import React from 'react'; import styled from 'styled-components'; import { - ChevronRightIcon, CloseIcon, - ExpandIcon, - PopupIcon, - SidebarIcon, + ConversationListIcon, + PlusIcon, + SettingsIcon, } from 'design/SVGIcon'; -import { AssistViewMode } from 'teleport/Assist/Assist'; import { useAssist } from 'teleport/Assist/context/AssistContext'; +import { Tooltip } from 'teleport/Assist/shared/Tooltip'; +import { ViewMode } from 'teleport/Assist/types'; +import { HeaderIcon } from 'teleport/Assist/shared'; interface HeaderProps { - viewMode: AssistViewMode; + viewMode: ViewMode; + sidebarVisible: boolean; onClose: () => void; - onExpand: () => void; - onDocking: () => void; - onViewModeChange: (viewMode: AssistViewMode) => void; + onToggleSidebar: () => void; + onSettingsOpen: () => void; + onError: (message: string) => void; } const Container = styled.header` @@ -45,25 +47,8 @@ const Container = styled.header` user-select: none; flex: 0 0 var(--assist-header-height); box-sizing: border-box; -`; - -const Icon = styled.div` - border-radius: 7px; - width: 38px; - height: 38px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: 0.2s ease-in-out opacity; - - svg { - transform: ${p => (p.rotated ? 'rotate(180deg)' : 'none')}; - } - - &:hover { - background: ${p => p.theme.colors.spotBackground[0]}; - } + position: relative; + z-index: 9999; `; const Icons = styled.section` @@ -75,96 +60,66 @@ const Icons = styled.section` const Title = styled.h2` margin: 0; font-size: 16px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; `; -function isExpanded(viewMode: AssistViewMode) { - return ( - viewMode === AssistViewMode.Expanded || - viewMode === AssistViewMode.ExpandedSidebarVisible - ); -} - -function toggleSidebarVisible(viewMode: AssistViewMode) { - switch (viewMode) { - case AssistViewMode.Expanded: - return AssistViewMode.ExpandedSidebarVisible; - - case AssistViewMode.ExpandedSidebarVisible: - return AssistViewMode.Expanded; - - case AssistViewMode.Docked: - return AssistViewMode.DockedSidebarVisible; - - case AssistViewMode.DockedSidebarVisible: - return AssistViewMode.Docked; - - case AssistViewMode.Collapsed: - return AssistViewMode.CollapsedSidebarVisible; - - case AssistViewMode.CollapsedSidebarVisible: - return AssistViewMode.Collapsed; - } -} - -function isDocked(viewMode: AssistViewMode) { - return ( - viewMode === AssistViewMode.Docked || - viewMode === AssistViewMode.DockedSidebarVisible - ); -} +const ConversationListIconWrapper = styled.div` + position: relative; + top: 5.5px; // align with the plus icon +`; export function Header(props: HeaderProps) { const { conversations: { selectedId, data }, + createConversation, } = useAssist(); const title = selectedId ? data.find(conversation => conversation.id === selectedId)?.title : 'Teleport Assist'; + async function handleCreateNewConversation() { + try { + await createConversation(); + } catch (err) { + props.onError('There was an error creating a new conversation.'); + } + } + return ( - {(props.viewMode === AssistViewMode.Collapsed || - props.viewMode === AssistViewMode.Docked) && ( - - props.onViewModeChange(toggleSidebarVisible(props.viewMode)) - } - > - - - )} - {isExpanded(props.viewMode) && ( - - props.onViewModeChange(toggleSidebarVisible(props.viewMode)) - } - > - - - )} + + + + + + + {props.sidebarVisible ? 'Close' : 'Open'} conversation history + + + + + + Start a new conversation + {title} - {!isDocked(props.viewMode) && ( - - - - )} - - {!isDocked(props.viewMode) ? ( - - ) : ( - - )} - - props.onClose()}> + + + + Open settings + + props.onClose()}> - + + Hide Assist + ); diff --git a/web/packages/teleport/src/Assist/Settings/DisplaySettings.tsx b/web/packages/teleport/src/Assist/Settings/DisplaySettings.tsx new file mode 100644 index 0000000000000..6f1d7ef50de5b --- /dev/null +++ b/web/packages/teleport/src/Assist/Settings/DisplaySettings.tsx @@ -0,0 +1,309 @@ +/** + * 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 styled from 'styled-components'; + +import logoLight from 'teleport/Navigation/logoLight.svg'; +import logoDark from 'teleport/Navigation/logoDark.svg'; +import { ViewMode } from 'teleport/Assist/types'; +import { Description, Title } from 'teleport/Assist/Settings/shared'; + +interface DisplaySettingsProps { + viewMode: ViewMode; + onChange: (viewMode: ViewMode) => void; +} + +const ViewModes = styled.div` + display: flex; + flex-wrap: wrap; + gap: 10px; +`; + +const ViewModeExample = styled.div` + width: 200px; + height: 100px; + box-shadow: 0 2px 5px 0 rgba(0, 0, 0, 0.2); + border-radius: 5px; + position: relative; + overflow: hidden; + display: flex; +`; + +const DimmedBackground = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.3); +`; + +const ViewModeContainer = styled.div` + color: ${p => + p.active + ? p.theme.colors.buttons.primary.default + : p.theme.colors.text.main}; + font-weight: ${p => (p.active ? 'bold' : 'normal')}; + cursor: pointer; + + ${ViewModeExample} { + border: 2px solid + ${p => + p.active ? p.theme.colors.buttons.primary.default : 'transparent'}; + } + + &:hover ${ViewModeExample} { + border: 2px solid ${p => p.theme.colors.buttons.primary.default}; + } +`; + +const Assist = styled.div` + background: ${p => p.theme.colors.levels.popout}; + box-shadow: 0 5px 10px 0 rgba(0, 0, 0, 0.3); + position: absolute; + overflow: hidden; +`; + +const Chat = styled.div` + display: flex; + flex-direction: column; + padding: 5px; +`; + +const Message = styled.div` + height: 6px; + flex: 0 0 6px; + margin-bottom: 5px; + background: ${p => + p.author === 'teleport' + ? p.theme.colors.levels.popout + : p.theme.colors.buttons.primary.default}; + align-self: ${p => (p.author === 'teleport' ? 'flex-start' : 'flex-end')}; + border-radius: 5px; + border: 1px solid ${p => p.theme.colors.spotBackground[1]}; +`; + +const PopupAssist = styled(Assist)` + top: 10px; + right: 10px; + width: 60px; + height: 60px; + border-radius: 7px; +`; + +const ExpandedAssist = styled(Assist)` + top: 10px; + right: 10px; + width: 120px; + height: 80px; + border-radius: 7px; + display: flex; + + ${Chat} { + flex: 1; + } +`; + +const DockedAssist = styled(Assist)` + position: static; + box-shadow: none; + border-left: 1px solid ${p => p.theme.colors.spotBackground[1]}; + flex: 0 0 60px; +`; + +const Conversation = styled.div` + height: 7px; + margin-bottom: 5px; + background: ${p => p.theme.colors.spotBackground[1]}; + border-radius: 3px; +`; + +const Sidebar = styled.div` + flex: 0 0 30px; + padding: 5px; + border-right: 1px solid ${p => p.theme.colors.spotBackground[1]}; +`; + +const Page = styled.div` + display: flex; + background: ${p => p.theme.colors.levels.sunken}; + height: inherit; + flex: 1; +`; + +const PageNavigation = styled.div` + background: ${p => p.theme.colors.levels.surface}; + flex: 0 0 45px; + height: inherit; + box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.2), + 0px 1px 1px rgba(0, 0, 0, 0.14), 0px 1px 3px rgba(0, 0, 0, 0.12); +`; + +const NavigationLogo = styled.div` + background: url(${p => (p.theme.name === 'light' ? logoLight : logoDark)}) + no-repeat; + background-size: contain; + width: 36px; + height: 32px; + margin-top: 5px; + margin-left: 5px; +`; + +const PageContent = styled.div` + flex: 1; +`; + +const PageHeader = styled.div` + height: 15px; + border-bottom: 1px solid ${p => p.theme.colors.spotBackground[0]}; +`; + +const PageTitle = styled.div` + font-size: 8px; + line-height: 10px; + margin-bottom: 5px; + color: ${p => p.theme.colors.text.main}; + font-weight: 400; +`; + +const PagePadding = styled.div` + padding: 5px; +`; + +const PageTable = styled.div` + background: ${p => p.theme.colors.levels.surface}; + width: 100%; + height: 40px; + border-radius: 3px; + box-shadow: 0px 2px 1px -1px rgba(0, 0, 0, 0.1), + 0px 1px 1px rgba(0, 0, 0, 0.07), 0px 1px 3px rgba(0, 0, 0, 0.06); +`; + +function MockPage() { + return ( + + + + + + + + + + Servers + + + + + + ); +} + +function MockChat() { + return ( + + + + + + + + + + + ); +} + +export function DisplaySettings(props: DisplaySettingsProps) { + return ( +
+ View Mode + + + Choose how you want Assist to be shown on your screen. + + + + props.onChange(ViewMode.Popup)} + > + + + + + + + + + + Popup + + props.onChange(ViewMode.Docked)} + > + + + + + + + + Docked + + props.onChange(ViewMode.PopupExpanded)} + > + + + + + + + + + + Expanded popup + + props.onChange(ViewMode.PopupExpandedSidebarVisible)} + > + + + + + + + + + + + + + + + + + Expanded popup with sidebar + + +
+ ); +} diff --git a/web/packages/teleport/src/Assist/Settings/RemoteExecutionSettings.tsx b/web/packages/teleport/src/Assist/Settings/RemoteExecutionSettings.tsx new file mode 100644 index 0000000000000..fd879a551bfe0 --- /dev/null +++ b/web/packages/teleport/src/Assist/Settings/RemoteExecutionSettings.tsx @@ -0,0 +1,224 @@ +/** + * 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, { useEffect, useRef, useState } from 'react'; +import styled from 'styled-components'; + +import Select, { Option } from 'shared/components/Select'; +import { CloseIcon } from 'design/SVGIcon'; + +import { DraggableList } from 'design/DraggableList'; + +import { Description, Title } from 'teleport/Assist/Settings/shared'; + +import { getNodesFromQuery } from 'teleport/Assist/service'; +import useStickyClusterId from 'teleport/useStickyClusterId'; + +import type { Node } from 'teleport/services/nodes/types'; + +interface RemoteExecutionSettingsProps { + preferredLogins: string[]; + onChange: (preferredLogins: string[]) => void; +} + +const Login = styled.div` + display: flex; + justify-content: space-between; + background: ${p => p.theme.colors.spotBackground[0]}; + width: 420px; + padding: 10px 15px; + border-radius: 5px; + overflow: hidden; + user-select: none; + cursor: grab; + position: relative; + height: 40px; + box-sizing: border-box; +`; + +const DeleteButton = styled.div` + cursor: pointer; + padding: 10px; + display: flex; + align-items: center; + justify-content: center; + background: ${p => p.theme.colors.spotBackground[0]}; + position: absolute; + right: 0; + top: 0; + bottom: 0; + + &:hover { + background: ${p => p.theme.colors.error.main}; + + svg path { + stroke: white; + } + } +`; + +const ErrorMessage = styled.div` + color: ${p => p.theme.colors.error.main}; +`; + +const Loading = styled.div` + color: ${props => props.theme.colors.text.muted}; +`; + +const LoginContainer = styled.div` + height: 50px; +`; + +function getLoginsForNodes(nodes: Node[]) { + const logins = new Set(); + + for (const node of nodes) { + for (const login of node.sshLogins) { + logins.add(login); + } + } + + return Array.from(logins); +} + +export function RemoteExecutionSettings(props: RemoteExecutionSettingsProps) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [logins, setLogins] = useState(props.preferredLogins); + const [, setCount] = useState(0); + + const order = useRef(logins.map((_, index) => index)); + + const [options, setOptions] = useState([]); + + const { clusterId } = useStickyClusterId(); + + async function fetchLogins() { + try { + const { agents } = await getNodesFromQuery('', clusterId); + + const logins = getLoginsForNodes(agents) + .filter(login => !props.preferredLogins.includes(login)) + .map(login => ({ label: login, value: login })); + + setOptions(logins); + } catch { + setError(true); + } + + setLoading(false); + } + + useEffect(() => { + fetchLogins(); + }, []); + + const loginsByOrder = order.current.map(index => logins[index]); + + function handleReorder(newOrder: number[]) { + order.current = newOrder; + + props.onChange(order.current.map(index => logins[index])); + + // force a re-render to update the number prefixed to the login (1., 2., etc.) + setCount(count => count + 1); + } + + function onChangeOption(e: Option) { + const newLogins = [...logins, e.value]; + + order.current = [...order.current, newLogins.length - 1]; + + setOptions(options.filter(o => o.value !== e.value)); + setLogins(newLogins); + + props.onChange(order.current.map(index => newLogins[index])); + } + + function handleRemove(login: string) { + const newLogins = logins.filter(l => l !== login); + + order.current = newLogins.map((_, index) => index); + + setLogins(newLogins); + setOptions([...options, { label: login, value: login }]); + + props.onChange(order.current.map(index => newLogins[index])); + } + + let content; + if (error) { + content = ( + + There was an issue fetching the available logins. + + ); + } else if (loading) { + content = Loading logins...; + } else { + content = ( + <> +
+