{selectedResources.length > 0 && (
@@ -396,14 +399,11 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
{text}
);
- if (tooltip) {
- return (
-
{tooltip}>}>
- {$button}
-
- );
- }
- return $button;
+ return (
+
+ {$button}
+
+ );
}
)}
>
@@ -434,41 +434,25 @@ export function UnifiedResources(props: UnifiedResourcesProps) {
{pinning.kind === 'not-supported' && params.pinnedOnly ? (
) : (
-
- {resources
- .map(unifiedResource => ({
- card: mapResourceToCard(unifiedResource),
- key: generateUnifiedResourceKey(unifiedResource.resource),
- }))
- .map(({ card, key }) => (
- handleSelectResources(key)}
- pinResource={() => handlePinResource(key)}
- />
- ))}
- {/* Using index as key here is ok because these elements never change order */}
- {(resourcesFetchAttempt.status === 'processing' ||
- getPinnedResourcesAttempt.status === 'processing') && (
- }
- />
+
+ isProcessing={
+ resourcesFetchAttempt.status === 'processing' ||
+ getPinnedResourcesAttempt.status === 'processing'
+ }
+ mappedResources={resources.map(unifiedResource => ({
+ item: mapResourceToViewItem(unifiedResource),
+ key: generateUnifiedResourceKey(unifiedResource.resource),
+ }))}
+ />
)}
@@ -520,7 +504,7 @@ function getResourcePinningSupport(
return PinningSupport.Supported;
}
-export function generateUnifiedResourceKey(
+function generateUnifiedResourceKey(
resource: SharedUnifiedResource['resource']
): string {
if (resource.kind === 'node') {
@@ -586,11 +570,6 @@ function NoResults({
return null;
}
-const ResourcesContainer = styled(Flex)`
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
-`;
-
const ErrorBox = styled(Box)`
position: sticky;
top: 0;
@@ -617,75 +596,3 @@ const ListFooter = styled.div`
min-height: ${INDICATOR_SIZE};
text-align: center;
`;
-
-// TODO (avatus) extract to the shared package in ToolTip
-export const HoverTooltip: React.FC<{
- tipContent: React.ReactElement;
- fontSize?: number;
-}> = ({ tipContent, fontSize = 10, children }) => {
- const [anchorEl, setAnchorEl] = useState();
- const open = Boolean(anchorEl);
-
- function handlePopoverOpen(event) {
- setAnchorEl(event.currentTarget);
- }
-
- function handlePopoverClose() {
- setAnchorEl(null);
- }
-
- return (
-
- {children}
-
-
- {tipContent}
-
-
-
- );
-};
-
-const modalCss = () => `
- pointer-events: none;
-`;
-
-const StyledOnHover = styled(Text)`
- color: ${props => props.theme.colors.text.main};
- background-color: ${props => props.theme.colors.tooltip.background};
- max-width: 350px;
-`;
-
-function mapResourceToCard({ resource, ui }: SharedUnifiedResource) {
- switch (resource.kind) {
- case 'node':
- return makeUnifiedResourceCardNode(resource, ui);
- case 'db':
- return makeUnifiedResourceCardDatabase(resource, ui);
- case 'kube_cluster':
- return makeUnifiedResourceCardKube(resource, ui);
- case 'app':
- return makeUnifiedResourceCardApp(resource, ui);
- case 'windows_desktop':
- return makeUnifiedResourceCardDesktop(resource, ui);
- case 'user_group':
- return makeUnifiedResourceCardUserGroup(resource, ui);
- }
-}
diff --git a/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx
new file mode 100644
index 0000000000000..cba518e47c8c6
--- /dev/null
+++ b/web/packages/shared/components/UnifiedResources/shared/CopyButton.tsx
@@ -0,0 +1,78 @@
+/**
+ * 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, useRef, useEffect } from 'react';
+
+import ButtonIcon from 'design/ButtonIcon';
+import { Check, Copy } from 'design/Icon';
+import copyToClipboard from 'design/utils/copyToClipboard';
+
+import { HoverTooltip } from 'shared/components/ToolTip';
+
+export function CopyButton({
+ name,
+ mr,
+ ml,
+}: {
+ name: string;
+ mr?: number;
+ ml?: number;
+}) {
+ const copySuccess = 'Copied!';
+ const copyDefault = 'Click to copy';
+ const timeout = useRef>();
+ const copyAnchorEl = useRef(null);
+ const [copiedText, setCopiedText] = useState(copyDefault);
+
+ const clearCurrentTimeout = () => {
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ timeout.current = undefined;
+ }
+ };
+
+ const handleCopy = () => {
+ clearCurrentTimeout();
+ setCopiedText(copySuccess);
+ copyToClipboard(name);
+ // Change to default text after 1 second
+ timeout.current = setTimeout(() => {
+ setCopiedText(copyDefault);
+ }, 1000);
+ };
+
+ useEffect(() => {
+ return () => clearCurrentTimeout();
+ }, []);
+
+ return (
+
+
+ {copiedText === copySuccess ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx
new file mode 100644
index 0000000000000..f1e382ebf35ca
--- /dev/null
+++ b/web/packages/shared/components/UnifiedResources/shared/PinButton.tsx
@@ -0,0 +1,90 @@
+/**
+ * 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, { useRef } from 'react';
+
+import { PushPinFilled, PushPin } from 'design/Icon';
+import ButtonIcon from 'design/ButtonIcon';
+
+import { HoverTooltip } from 'shared/components/ToolTip';
+
+import { PinningSupport } from '../types';
+
+import { PINNING_NOT_SUPPORTED_MESSAGE } from '../UnifiedResources';
+
+export function PinButton({
+ pinned,
+ pinningSupport,
+ hovered,
+ setPinned,
+ className,
+}: {
+ pinned: boolean;
+ pinningSupport: PinningSupport;
+ hovered: boolean;
+ setPinned: () => void;
+ className?: string;
+}) {
+ const copyAnchorEl = useRef(null);
+ const tipContent = getTipContent(pinningSupport, pinned);
+
+ const shouldShowButton =
+ pinningSupport !== PinningSupport.Hidden && (pinned || hovered);
+ const shouldDisableButton =
+ pinningSupport === PinningSupport.Disabled ||
+ pinningSupport === PinningSupport.NotSupported;
+
+ const $content = pinned ? (
+
+ ) : (
+
+ );
+
+ return (
+
+ {tipContent && shouldShowButton ? (
+ {$content}
+ ) : (
+ $content
+ )}
+
+
+ );
+}
+
+function getTipContent(
+ pinningSupport: PinningSupport,
+ pinned: boolean
+): string {
+ switch (pinningSupport) {
+ case PinningSupport.NotSupported:
+ return PINNING_NOT_SUPPORTED_MESSAGE;
+ case PinningSupport.Supported:
+ return pinned ? 'Unpin' : 'Pin';
+ default:
+ return '';
+ }
+}
diff --git a/web/packages/shared/components/UnifiedResources/cards.ts b/web/packages/shared/components/UnifiedResources/shared/viewItemsFactory.ts
similarity index 63%
rename from web/packages/shared/components/UnifiedResources/cards.ts
rename to web/packages/shared/components/UnifiedResources/shared/viewItemsFactory.ts
index 336a856306614..552a0f759c3b6 100644
--- a/web/packages/shared/components/UnifiedResources/cards.ts
+++ b/web/packages/shared/components/UnifiedResources/shared/viewItemsFactory.ts
@@ -14,133 +14,151 @@
* limitations under the License.
*/
-import React from 'react';
-import { ResourceIconName } from 'design/ResourceIcon';
-
import {
- Icon,
Application as ApplicationIcon,
Database as DatabaseIcon,
Kubernetes as KubernetesIcon,
Server as ServerIcon,
Desktop as DesktopIcon,
} from 'design/Icon';
+import { ResourceIconName } from 'design/ResourceIcon';
import { DbProtocol } from 'shared/services/databases';
import { NodeSubKind } from 'shared/services';
import {
- UnifiedResourceKube,
- UnifiedResourceNode,
+ UnifiedResourceViewItem,
UnifiedResourceUi,
- UnifiedResourceDatabase,
+ UnifiedResourceNode,
UnifiedResourceApp,
- UnifiedResourceUserGroup,
+ UnifiedResourceDatabase,
UnifiedResourceDesktop,
-} from './types';
-
-export interface UnifiedResourceCard {
- name: string;
- description: {
- primary?: string;
- secondary?: string;
- };
- labels: {
- name: string;
- value: string;
- }[];
- primaryIconName: ResourceIconName;
- SecondaryIcon: typeof Icon;
- ActionButton: React.ReactElement;
-}
+ UnifiedResourceKube,
+ UnifiedResourceUserGroup,
+ SharedUnifiedResource,
+} from '../types';
-export function makeUnifiedResourceCardNode(
+export function makeUnifiedResourceViewItemNode(
resource: UnifiedResourceNode,
ui: UnifiedResourceUi
-): UnifiedResourceCard {
+): UnifiedResourceViewItem {
+ const nodeSubKind = formatNodeSubKind(resource.subKind);
+ const addressIfNotTunnel = resource.tunnel ? '' : resource.addr;
+
return {
name: resource.hostname,
SecondaryIcon: ServerIcon,
primaryIconName: 'Server',
ActionButton: ui.ActionButton,
labels: resource.labels,
- description: {
- primary: formatNodeSubKind(resource.subKind),
- secondary: resource.tunnel ? '' : resource.addr,
+ cardViewProps: {
+ primaryDesc: nodeSubKind,
+ secondaryDesc: addressIfNotTunnel,
+ },
+ listViewProps: {
+ resourceType: nodeSubKind,
+ addr: addressIfNotTunnel,
},
};
}
-export function makeUnifiedResourceCardDatabase(
+export function makeUnifiedResourceViewItemDatabase(
resource: UnifiedResourceDatabase,
ui: UnifiedResourceUi
-): UnifiedResourceCard {
+): UnifiedResourceViewItem {
return {
name: resource.name,
SecondaryIcon: DatabaseIcon,
primaryIconName: getDatabaseIconName(resource.protocol),
ActionButton: ui.ActionButton,
labels: resource.labels,
- description: { primary: resource.type, secondary: resource.description },
+ listViewProps: {
+ description: resource.description,
+ resourceType: resource.type,
+ },
+ cardViewProps: {
+ primaryDesc: resource.type,
+ secondaryDesc: resource.description,
+ },
};
}
-export function makeUnifiedResourceCardKube(
+export function makeUnifiedResourceViewItemKube(
resource: UnifiedResourceKube,
ui: UnifiedResourceUi
-): UnifiedResourceCard {
+): UnifiedResourceViewItem {
return {
name: resource.name,
SecondaryIcon: KubernetesIcon,
primaryIconName: 'Kube',
ActionButton: ui.ActionButton,
labels: resource.labels,
- description: { primary: 'Kubernetes' },
+ cardViewProps: {
+ primaryDesc: 'Kubernetes',
+ },
+ listViewProps: {
+ resourceType: 'Kubernetes',
+ },
};
}
-export function makeUnifiedResourceCardApp(
+export function makeUnifiedResourceViewItemApp(
resource: UnifiedResourceApp,
ui: UnifiedResourceUi
-): UnifiedResourceCard {
+): UnifiedResourceViewItem {
return {
name: resource.name,
SecondaryIcon: ApplicationIcon,
primaryIconName: guessAppIcon(resource),
ActionButton: ui.ActionButton,
labels: resource.labels,
- description: {
- primary: resource.description,
- secondary: resource.addrWithProtocol,
+ cardViewProps: {
+ primaryDesc: resource.description,
+ secondaryDesc: resource.addrWithProtocol,
+ },
+ listViewProps: {
+ resourceType: resource.samlApp ? 'SAML Application' : 'Application',
+ description: resource.samlApp ? '' : resource.description,
+ addr: resource.addrWithProtocol,
},
};
}
-export function makeUnifiedResourceCardDesktop(
+export function makeUnifiedResourceViewItemDesktop(
resource: UnifiedResourceDesktop,
ui: UnifiedResourceUi
-): UnifiedResourceCard {
+): UnifiedResourceViewItem {
return {
name: resource.name,
SecondaryIcon: DesktopIcon,
primaryIconName: 'Windows',
ActionButton: ui.ActionButton,
labels: resource.labels,
- description: { primary: 'Windows', secondary: resource.addr },
+ cardViewProps: {
+ primaryDesc: 'Windows',
+ secondaryDesc: resource.addr,
+ },
+ listViewProps: {
+ resourceType: 'Windows',
+ addr: resource.addr,
+ },
};
}
-export function makeUnifiedResourceCardUserGroup(
+export function makeUnifiedResourceViewItemUserGroup(
resource: UnifiedResourceUserGroup,
ui: UnifiedResourceUi
-): UnifiedResourceCard {
+): UnifiedResourceViewItem {
return {
name: resource.name,
SecondaryIcon: ServerIcon,
primaryIconName: 'Server',
ActionButton: ui.ActionButton,
labels: resource.labels,
- description: {},
+ cardViewProps: {},
+ listViewProps: {
+ resourceType: 'User Group',
+ },
};
}
@@ -209,3 +227,20 @@ function getDatabaseIconName(protocol: DbProtocol): ResourceIconName {
return 'Database';
}
}
+
+export function mapResourceToViewItem({ resource, ui }: SharedUnifiedResource) {
+ switch (resource.kind) {
+ case 'node':
+ return makeUnifiedResourceViewItemNode(resource, ui);
+ case 'db':
+ return makeUnifiedResourceViewItemDatabase(resource, ui);
+ case 'kube_cluster':
+ return makeUnifiedResourceViewItemKube(resource, ui);
+ case 'app':
+ return makeUnifiedResourceViewItemApp(resource, ui);
+ case 'windows_desktop':
+ return makeUnifiedResourceViewItemDesktop(resource, ui);
+ case 'user_group':
+ return makeUnifiedResourceViewItemUserGroup(resource, ui);
+ }
+}
diff --git a/web/packages/shared/components/UnifiedResources/types.ts b/web/packages/shared/components/UnifiedResources/types.ts
index 377ccbecfa58a..84663db1b685b 100644
--- a/web/packages/shared/components/UnifiedResources/types.ts
+++ b/web/packages/shared/components/UnifiedResources/types.ts
@@ -18,6 +18,9 @@ import React from 'react';
import { ResourceLabel } from 'teleport/services/agents';
+import { ResourceIconName } from 'design/ResourceIcon';
+import { Icon } from 'design/Icon';
+
import { DbProtocol } from 'shared/services/databases';
import { NodeSubKind } from 'shared/services';
@@ -30,6 +33,7 @@ export type UnifiedResourceApp = {
awsConsole: boolean;
addrWithProtocol?: string;
friendlyName?: string;
+ samlApp: boolean;
};
export interface UnifiedResourceDatabase {
@@ -100,3 +104,84 @@ export type UnifiedResourcesQueryParams = {
// TODO(bl-nero): Remove this once filters are expressed as advanced search.
kinds?: string[];
};
+export interface UnifiedResourceViewItem {
+ name: string;
+ labels: {
+ name: string;
+ value: string;
+ }[];
+ primaryIconName: ResourceIconName;
+ SecondaryIcon: typeof Icon;
+ ActionButton: React.ReactElement;
+ cardViewProps: CardViewSpecificProps;
+ listViewProps: ListViewSpecificProps;
+}
+
+export enum PinningSupport {
+ Supported = 'Supported',
+ /**
+ * Disables pinning functionality if a leaf cluster hasn't been upgraded yet.
+ * Shows an appropriate message on hover.
+ * */
+ NotSupported = 'NotSupported',
+ /** Disables the pinning button. */
+ Disabled = 'Disabled',
+ /** Hides the pinning button completely. */
+ Hidden = 'Hidden',
+}
+
+export type ResourceItemProps = {
+ name: string;
+ primaryIconName: ResourceIconName;
+ SecondaryIcon: typeof Icon;
+ cardViewProps: CardViewSpecificProps;
+ listViewProps: ListViewSpecificProps;
+ labels: ResourceLabel[];
+ ActionButton: React.ReactElement;
+ onLabelClick?: (label: ResourceLabel) => void;
+ pinResource: () => void;
+ selectResource: () => void;
+ selected: boolean;
+ pinned: boolean;
+ pinningSupport: PinningSupport;
+};
+
+// Props that are needed for the Card view.
+// The reason we need this separately defined is because unlike with the list view, what we display in the
+// description sections of a card varies based on the type of its resource. For example, for applications,
+// instead of showing the `Application` type under the name like we would for other resources, we show the description.
+type CardViewSpecificProps = {
+ primaryDesc?: string;
+ secondaryDesc?: string;
+};
+
+type ListViewSpecificProps = {
+ description?: string;
+ addr?: string;
+ resourceType: string;
+};
+
+export type UnifiedResourcesPinning =
+ | {
+ kind: 'supported';
+ /** `getClusterPinnedResources` has to be stable, it is used in `useEffect`. */
+ getClusterPinnedResources(): Promise;
+ updateClusterPinnedResources(pinned: string[]): Promise;
+ }
+ | {
+ kind: 'not-supported';
+ }
+ | {
+ kind: 'hidden';
+ };
+
+export type ResourceViewProps = {
+ onLabelClick: (label: ResourceLabel) => void;
+ pinnedResources: string[];
+ selectedResources: string[];
+ onSelectResource: (resourceId: string) => void;
+ onPinResource: (resourceId: string) => void;
+ pinningSupport: PinningSupport;
+ isProcessing: boolean;
+ mappedResources: { item: UnifiedResourceViewItem; key: string }[];
+};
diff --git a/web/packages/shared/components/UnifiedResources/unifiedStyles.css b/web/packages/shared/components/UnifiedResources/unifiedStyles.css
index 384769cb96fc7..67e72bbf99ef3 100644
--- a/web/packages/shared/components/UnifiedResources/unifiedStyles.css
+++ b/web/packages/shared/components/UnifiedResources/unifiedStyles.css
@@ -23,6 +23,18 @@
container-type: inline-size;
}
+.CardsContainer {
+ @container (min-width: 1600px) {
+ grid-template-columns: repeat(4, minmax(400px, 1fr));
+ }
+}
+
+.ListContainer {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
.SearchPanel {
width: 100%;
@container (min-width: 800px) {
diff --git a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
index c988957a66c37..1fecd08886e52 100644
--- a/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
+++ b/web/packages/teleport/src/UnifiedResources/UnifiedResources.tsx
@@ -20,8 +20,8 @@ import { Flex } from 'design';
import {
UnifiedResources as SharedUnifiedResources,
- UnifiedResourcesPinning,
useUnifiedResourcesFetch,
+ UnifiedResourcesPinning,
} from 'shared/components/UnifiedResources';
import useStickyClusterId from 'teleport/useStickyClusterId';
@@ -176,6 +176,7 @@ function ClusterResources({
params={params}
fetchResources={fetch}
resourcesFetchAttempt={attempt}
+ unifiedResourcePreferences={preferences.unifiedResourcePreferences}
updateUnifiedResourcesPreferences={preferences => {
updatePreferences({ unifiedResourcePreferences: preferences });
}}
diff --git a/web/packages/teleport/src/services/userPreferences/types.ts b/web/packages/teleport/src/services/userPreferences/types.ts
index c0dc727c757c8..24d5e6ad4466e 100644
--- a/web/packages/teleport/src/services/userPreferences/types.ts
+++ b/web/packages/teleport/src/services/userPreferences/types.ts
@@ -28,6 +28,11 @@ export enum UnifiedTabPreference {
Pinned = 2,
}
+export enum UnifiedViewModePreference {
+ Card = 1,
+ List = 2,
+}
+
export enum ClusterResource {
RESOURCE_UNSPECIFIED = 0,
RESOURCE_WINDOWS_DESKTOPS = 1,
@@ -68,6 +73,8 @@ export interface UserClusterPreferences {
export interface UnifiedResourcePreferences {
// defaultTab is the default tab selected in the unified resource view
defaultTab: UnifiedTabPreference;
+ // viewMode is the view mode selected in the unified resource view (Card/List).
+ viewMode: UnifiedViewModePreference;
}
export type GetUserClusterPreferencesResponse = UserClusterPreferences;
diff --git a/web/packages/teleport/src/services/userPreferences/userPreferences.ts b/web/packages/teleport/src/services/userPreferences/userPreferences.ts
index 4860ab1e1dc35..4b625b513d385 100644
--- a/web/packages/teleport/src/services/userPreferences/userPreferences.ts
+++ b/web/packages/teleport/src/services/userPreferences/userPreferences.ts
@@ -21,6 +21,7 @@ import { ViewMode } from 'teleport/Assist/types';
import {
ThemePreference,
UnifiedTabPreference,
+ UnifiedViewModePreference,
} from 'teleport/services/userPreferences/types';
import { KeysEnum } from '../localStorage';
@@ -92,6 +93,7 @@ export function makeDefaultUserPreferences(): UserPreferences {
},
unifiedResourcePreferences: {
defaultTab: UnifiedTabPreference.All,
+ viewMode: UnifiedViewModePreference.Card,
},
clusterPreferences: makeDefaultUserClusterPreferences(),
};