From 91553da59138f8a5148b42caab64bcb03aaac955 Mon Sep 17 00:00:00 2001 From: matheus Date: Fri, 20 Dec 2024 13:56:21 -0300 Subject: [PATCH] Add Cluster Management page (#47899) (#50212) * (wip) manage clusters page * Route for non-root cluster * Minor adjustments * Missing license header * UI improvements * Add `isCloud` to cluster info * Initial loading state * Storybook, tests * Lint fix, godoc, etc * Unexport getClusterInfo response * rename `c` to clusterDetails * Inline `IsCloud` * Import order and useAsync * Remove unnecessary godoc * Apply suggestions from code review: add blank line * address code review comments: import order, JS docs * Use msw for testing * Assert error message before asserting that the version is not shown --------- Co-authored-by: Sakshyam Shah --- lib/web/apiserver.go | 32 +++ .../src/Clusters/ClusterList/ClusterList.tsx | 7 + .../teleport/src/Clusters/Clusters.story.tsx | 4 +- .../teleport/src/Clusters/Clusters.tsx | 21 ++ .../ManageCluster/ManageCluster.story.tsx | 73 ++++++ .../ManageCluster/ManageCluster.test.tsx | 106 +++++++++ .../Clusters/ManageCluster/ManageCluster.tsx | 216 ++++++++++++++++++ .../src/Clusters/ManageCluster/index.ts | 19 ++ .../teleport/src/Clusters/fixtures/index.ts | 15 ++ web/packages/teleport/src/config.ts | 13 ++ web/packages/teleport/src/features.tsx | 4 + .../src/services/clusters/clusters.ts | 6 +- .../src/services/clusters/makeCluster.ts | 8 +- .../teleport/src/services/clusters/types.ts | 4 + 14 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.story.tsx create mode 100644 web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.test.tsx create mode 100644 web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.tsx create mode 100644 web/packages/teleport/src/Clusters/ManageCluster/index.ts diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 5733e8b7c3754..bea28ef807642 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -815,6 +815,9 @@ func (h *Handler) bindDefaultEndpoints() { // Site specific API + // get site info + h.GET("/webapi/sites/:site/info", h.WithClusterAuth(h.getClusterInfo)) + // get namespaces h.GET("/webapi/sites/:site/namespaces", h.WithClusterAuth(h.getSiteNamespaces)) @@ -2890,6 +2893,35 @@ func (h *Handler) getClusters(w http.ResponseWriter, r *http.Request, p httprout return out, nil } +type getClusterInfoResponse struct { + ui.Cluster + IsCloud bool `json:"isCloud"` +} + +// getClusterInfo returns the information about the cluster in the :site param +func (h *Handler) getClusterInfo(w http.ResponseWriter, r *http.Request, p httprouter.Params, sctx *SessionContext, site reversetunnelclient.RemoteSite) (interface{}, error) { + ctx := r.Context() + clusterDetails, err := ui.GetClusterDetails(ctx, site) + if err != nil { + return nil, trace.Wrap(err) + } + + clt, err := sctx.GetUserClient(ctx, site) + if err != nil { + return nil, trace.Wrap(err) + } + + pingResp, err := clt.Ping(ctx) + if err != nil { + return nil, trace.Wrap(err) + } + + return getClusterInfoResponse{ + Cluster: *clusterDetails, + IsCloud: pingResp.GetServerFeatures().Cloud, + }, nil +} + type getSiteNamespacesResponse struct { Namespaces []types.Namespace `json:"namespaces"` } diff --git a/web/packages/teleport/src/Clusters/ClusterList/ClusterList.tsx b/web/packages/teleport/src/Clusters/ClusterList/ClusterList.tsx index b15118c38e98c..293bf38d15c90 100644 --- a/web/packages/teleport/src/Clusters/ClusterList/ClusterList.tsx +++ b/web/packages/teleport/src/Clusters/ClusterList/ClusterList.tsx @@ -26,6 +26,7 @@ import { Primary, Secondary } from 'design/Label'; import { Cluster } from 'teleport/services/clusters'; import cfg from 'teleport/config'; +import { DropdownDivider } from 'teleport/components/Dropdown'; export default function ClustersList(props: Props) { const { clusters = [], pageSize = 50, menuFlags } = props; @@ -84,6 +85,12 @@ function renderActionCell({ clusterId }: Cluster, flags: MenuFlags) { ); } + $items.push(); + + $items.push( + renderMenuItem('Manage Cluster', cfg.getManageClusterRoute(clusterId)) + ); + return ( {$items && } ); diff --git a/web/packages/teleport/src/Clusters/Clusters.story.tsx b/web/packages/teleport/src/Clusters/Clusters.story.tsx index c4eb703b9ab88..04d3b1d2e6dd4 100644 --- a/web/packages/teleport/src/Clusters/Clusters.story.tsx +++ b/web/packages/teleport/src/Clusters/Clusters.story.tsx @@ -25,7 +25,7 @@ import { FeaturesContextProvider } from 'teleport/FeaturesContext'; import { getOSSFeatures } from 'teleport/features'; -import { Clusters } from './Clusters'; +import { ClusterListPage } from './Clusters'; import * as fixtures from './fixtures'; export default { @@ -39,7 +39,7 @@ export function Story({ value }: { value: teleport.Context }) { - + diff --git a/web/packages/teleport/src/Clusters/Clusters.tsx b/web/packages/teleport/src/Clusters/Clusters.tsx index 6ded2d3f0591a..6030e33be988d 100644 --- a/web/packages/teleport/src/Clusters/Clusters.tsx +++ b/web/packages/teleport/src/Clusters/Clusters.tsx @@ -30,11 +30,32 @@ import { import useTeleport from 'teleport/useTeleport'; import { useFeatures } from 'teleport/FeaturesContext'; +import { Route, Switch } from 'teleport/components/Router'; +import cfg from 'teleport/config'; import ClusterList from './ClusterList'; import { buildACL } from './utils'; +import { ManageCluster } from './ManageCluster'; export function Clusters() { + return ( + + + + + ); +} + +export function ClusterListPage() { const ctx = useTeleport(); const [clusters, setClusters] = useState([]); diff --git a/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.story.tsx b/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.story.tsx new file mode 100644 index 0000000000000..8d6732bd3b743 --- /dev/null +++ b/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.story.tsx @@ -0,0 +1,73 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { MemoryRouter } from 'react-router'; + +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { ContextProvider } from 'teleport/index'; +import { ContentMinWidth } from 'teleport/Main/Main'; +import { Route } from 'teleport/components/Router'; + +import { clusterInfoFixture } from '../fixtures'; + +import { ManageCluster } from './ManageCluster'; + +export default { + title: 'Teleport/Clusters/ManageCluster', +}; + +function render(fetchClusterDetails: (clusterId: string) => Promise) { + const ctx = createTeleportContext(); + + ctx.clusterService.fetchClusterDetails = fetchClusterDetails; + return ( + + + + + + + + + + ); +} + +export function Loading() { + const fetchClusterDetails = () => { + // promise never resolves to simulate loading state + return new Promise(() => {}); + }; + return render(fetchClusterDetails); +} + +export function Failed() { + const fetchClusterDetails = () => + Promise.reject(new Error('Failed to load cluster details')); + return render(fetchClusterDetails); +} + +export function Success() { + const fetchClusterDetails = () => { + return new Promise(resolve => { + resolve(clusterInfoFixture); + }); + }; + return render(fetchClusterDetails); +} diff --git a/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.test.tsx b/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.test.tsx new file mode 100644 index 0000000000000..0d137476956a4 --- /dev/null +++ b/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.test.tsx @@ -0,0 +1,106 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { MemoryRouter, Route } from 'react-router-dom'; +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; + +import { render, waitFor, screen } from 'design/utils/testing'; + +import { ContextProvider } from 'teleport/index'; +import { createTeleportContext } from 'teleport/mocks/contexts'; +import { ContentMinWidth } from 'teleport/Main/Main'; +import cfg from 'teleport/config'; + +import { clusterInfoFixture } from '../fixtures'; + +import { ManageCluster } from './ManageCluster'; + +function renderElement(element, ctx) { + return render( + + + + {element} + + + + ); +} + +describe('test ManageCluster component', () => { + const server = setupServer( + http.get(cfg.getClusterInfoPath('cluster-id'), () => { + return HttpResponse.json({ + name: 'cluster-id', + lastConnected: new Date(), + status: 'active', + publicURL: 'cluster-id.teleport.com', + authVersion: 'v17.0.0', + proxyVersion: 'v17.0.0', + isCloud: false, + licenseExpiry: new Date(), + }); + }) + ); + + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + + test('fetches cluster information on load', async () => { + const ctx = createTeleportContext(); + + renderElement(, ctx); + await waitFor(() => { + expect(screen.getByText('v17.0.0')).toBeInTheDocument(); + }); + + expect(screen.getByText('cluster-id')).toBeInTheDocument(); + expect(screen.getByText('cluster-id.teleport.com')).toBeInTheDocument(); + }); + + test('shows error when load fails', async () => { + server.use( + http.get(cfg.getClusterInfoPath('cluster-id'), () => { + return HttpResponse.json( + { + message: 'Failed to load cluster information', + }, + { status: 400 } + ); + }) + ); + + const ctx = createTeleportContext(); + + renderElement(, ctx); + await waitFor(() => { + expect( + screen.getByText('Failed to load cluster information') + ).toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.queryByText(clusterInfoFixture.authVersion) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.tsx b/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.tsx new file mode 100644 index 0000000000000..e349e26b5098c --- /dev/null +++ b/web/packages/teleport/src/Clusters/ManageCluster/ManageCluster.tsx @@ -0,0 +1,216 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Link } from 'react-router-dom'; +import { useParams } from 'react-router'; + +import { useAsync, Attempt } from 'shared/hooks/useAsync'; + +import { MultiRowBox, Row } from 'design/MultiRowBox'; +import Flex from 'design/Flex'; +import * as Icons from 'design/Icon'; +import Text, { H2 } from 'design/Text'; +import { Indicator } from 'design/Indicator'; +import Box, { BoxProps } from 'design/Box'; +import { ShimmerBox } from 'design/ShimmerBox'; +import { Alert } from 'design/Alert'; + +import { LoadingSkeleton } from 'shared/components/UnifiedResources/shared/LoadingSkeleton'; + +import { + FeatureBox, + FeatureHeader, + FeatureHeaderTitle, +} from 'teleport/components/Layout'; +import { useTeleport } from 'teleport/index'; +import cfg from 'teleport/config'; +import { useNoMinWidth } from 'teleport/Main'; +import { ClusterInfo } from 'teleport/services/clusters'; + +/** + * OSS Cluster Management page. + * @returns JSX.Element + */ +export function ManageCluster() { + const [cluster, setCluster] = useState(null); + const ctx = useTeleport(); + + const { clusterId } = useParams<{ + clusterId: string; + }>(); + + const [attempt, run] = useAsync( + useCallback(async () => { + const res = await ctx.clusterService.fetchClusterDetails(clusterId); + setCluster(res); + return res; + }, [clusterId, ctx.clusterService]) + ); + + useEffect(() => { + if (!attempt.status && clusterId) { + run(); + } + }, [attempt.status, run, clusterId]); + + useNoMinWidth(); + + return ( + + + {attempt.status === 'processing' ? ( + + + + ) : ( + + )} + + ); +} + +export function ManageClusterHeader({ clusterId }: { clusterId: string }) { + return ( + + + + + + Manage Clusters + / + {clusterId} + + + + + ); +} + +type ClusterInformationProps = { + cluster?: ClusterInfo; + style?: React.CSSProperties; + attempt: Attempt; +} & BoxProps; + +export function ClusterInformation({ + cluster, + style, + attempt, + ...rest +}: ClusterInformationProps) { + const isLoading = attempt.status === 'processing'; + return ( + + + + + + +

Cluster Information

+
+
+ + {attempt.status === 'error' && {attempt.statusText}} + {attempt.status !== 'error' && ( + <> + + + + {cfg.tunnelPublicAddress && ( + + )} + {cfg.edition === 'ent' && + !cfg.isCloud && + cluster?.licenseExpiryDateText && ( + + )} + + )} + +
+ ); +} + +export const IconBox = styled(Box)` + line-height: 0; + padding: ${props => props.theme.space[2]}px; + border-radius: ${props => props.theme.radii[3]}px; + margin-right: ${props => props.theme.space[3]}px; + background: ${props => props.theme.colors.interactive.tonal.neutral[0]}; +`; + +export const DataItem = ({ title = '', data = null, isLoading = false }) => ( + + + {title}: + + {isLoading ? ( + + } + /> + ) : ( + {data} + )} + +); + +const DataItemFlex = styled(Flex)` + margin-bottom: ${props => props.theme.space[3]}px; + align-items: center; + @media screen and (max-width: ${props => props.theme.breakpoints.mobile}px) { + flex-direction: column; + padding-left: ${props => props.theme.space[2]}px; + align-items: start; + } +`; + +function randomRange(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min; +} diff --git a/web/packages/teleport/src/Clusters/ManageCluster/index.ts b/web/packages/teleport/src/Clusters/ManageCluster/index.ts new file mode 100644 index 0000000000000..3708a63541576 --- /dev/null +++ b/web/packages/teleport/src/Clusters/ManageCluster/index.ts @@ -0,0 +1,19 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +export { ManageCluster } from './ManageCluster'; diff --git a/web/packages/teleport/src/Clusters/fixtures/index.ts b/web/packages/teleport/src/Clusters/fixtures/index.ts index 41344401a5a7d..ab99fcac55d9c 100644 --- a/web/packages/teleport/src/Clusters/fixtures/index.ts +++ b/web/packages/teleport/src/Clusters/fixtures/index.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { ClusterInfo } from 'teleport/services/clusters'; + export const clusters = [ { clusterId: 'localhost', @@ -428,3 +430,16 @@ export const clusters = [ proxyVersion: '1.14.3', }, ]; + +export const clusterInfoFixture: ClusterInfo = { + authVersion: 'v17.0.0', + clusterId: 'cluster-id', + connectedText: '', + isCloud: false, + lastConnected: new Date(), + proxyVersion: 'v17.0.0', + publicURL: 'example.teleport.com', + status: 'active', + url: 'example.teleport.com', + licenseExpiryDateText: new Date().toISOString(), +}; diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index b9c74f6c1ea0e..6980b827784cd 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -158,6 +158,8 @@ const cfg = { sso: '/web/sso', cluster: '/web/cluster/:clusterId/', clusters: '/web/clusters', + manageCluster: '/web/clusters/:clusterId/manage', + trustedClusters: '/web/trust', audit: '/web/cluster/:clusterId/audit', unifiedResources: '/web/cluster/:clusterId/resources', @@ -224,6 +226,7 @@ const cfg = { applicationsPath: '/v1/webapi/sites/:clusterId/apps?searchAsRoles=:searchAsRoles?&limit=:limit?&startKey=:startKey?&query=:query?&search=:search?&sort=:sort?', clustersPath: '/v1/webapi/sites', + clusterInfoPath: '/v1/webapi/sites/:clusterId/info', clusterAlertsPath: '/v1/webapi/sites/:clusterId/alerts', clusterEventsPath: `/v1/webapi/sites/:clusterId/events/search?from=:start?&to=:end?&limit=:limit?&startKey=:startKey?&include=:include?`, clusterEventsRecordingsPath: `/v1/webapi/sites/:clusterId/events/search/sessions?from=:start?&to=:end?&limit=:limit?&startKey=:startKey?`, @@ -428,6 +431,12 @@ const cfg = { return cfg.playable_db_protocols; }, + getClusterInfoPath(clusterId: string) { + return generatePath(cfg.api.clusterInfoPath, { + clusterId, + }); + }, + getUserClusterPreferencesUrl(clusterId: string) { return generatePath(cfg.api.userClusterPreferencesPath, { clusterId, @@ -543,6 +552,10 @@ const cfg = { return generatePath(cfg.routes.nodes, { clusterId }); }, + getManageClusterRoute(clusterId: string) { + return generatePath(cfg.routes.manageCluster, { clusterId }); + }, + getUnifiedResourcesRoute(clusterId: string) { return generatePath(cfg.routes.unifiedResources, { clusterId }); }, diff --git a/web/packages/teleport/src/features.tsx b/web/packages/teleport/src/features.tsx index 54f3cb8f41916..7c26132fcf600 100644 --- a/web/packages/teleport/src/features.tsx +++ b/web/packages/teleport/src/features.tsx @@ -581,6 +581,10 @@ export class FeatureClusters implements TeleportFeature { }, searchableTags: ['clusters', 'manage clusters'], }; + + getRoute() { + return this.route; + } } export class FeatureTrust implements TeleportFeature { diff --git a/web/packages/teleport/src/services/clusters/clusters.ts b/web/packages/teleport/src/services/clusters/clusters.ts index 9aa15cf7c81fb..a912a315433e0 100644 --- a/web/packages/teleport/src/services/clusters/clusters.ts +++ b/web/packages/teleport/src/services/clusters/clusters.ts @@ -19,7 +19,7 @@ import api from 'teleport/services/api'; import cfg from 'teleport/config'; -import { makeClusterList } from './makeCluster'; +import { makeClusterInfo, makeClusterList } from './makeCluster'; import { Cluster } from '.'; @@ -39,4 +39,8 @@ export default class ClustersService { } return Promise.resolve(this.clusters); } + + fetchClusterDetails(clusterId) { + return api.get(cfg.getClusterInfoPath(clusterId)).then(makeClusterInfo); + } } diff --git a/web/packages/teleport/src/services/clusters/makeCluster.ts b/web/packages/teleport/src/services/clusters/makeCluster.ts index 78967d4f430f9..1a3165054ea9c 100644 --- a/web/packages/teleport/src/services/clusters/makeCluster.ts +++ b/web/packages/teleport/src/services/clusters/makeCluster.ts @@ -20,7 +20,7 @@ import { displayDate, displayDateTime } from 'design/datetime'; import cfg from 'teleport/config'; -import { Cluster } from './types'; +import { Cluster, ClusterInfo } from './types'; export function makeCluster(json): Cluster { const { @@ -54,6 +54,12 @@ export function makeCluster(json): Cluster { }; } +export function makeClusterInfo(json): ClusterInfo { + const isCloud = json.isCloud; + const cluster = makeCluster(json); + return { ...cluster, isCloud }; +} + export function makeClusterList(json: any): Cluster[] { json = json || []; diff --git a/web/packages/teleport/src/services/clusters/types.ts b/web/packages/teleport/src/services/clusters/types.ts index a036f61b7860f..2d555f1d2059c 100644 --- a/web/packages/teleport/src/services/clusters/types.ts +++ b/web/packages/teleport/src/services/clusters/types.ts @@ -27,3 +27,7 @@ export interface Cluster { proxyVersion: string; licenseExpiryDateText?: string; } + +export type ClusterInfo = { + isCloud: boolean; +} & Cluster;