Skip to content

Commit

Permalink
[v16] Add Cluster Management page (#50213)
Browse files Browse the repository at this point in the history
* Add Cluster Management page (#47899)

* (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

Co-authored-by: Sakshyam Shah <[email protected]>

* 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 <[email protected]>

* adapt to v16

---------

Co-authored-by: Sakshyam Shah <[email protected]>
  • Loading branch information
mcbattirola and flyinghermit authored Dec 20, 2024
1 parent 04d1a52 commit cc15bef
Show file tree
Hide file tree
Showing 14 changed files with 526 additions and 4 deletions.
32 changes: 32 additions & 0 deletions lib/web/apiserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -820,6 +820,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))

Expand Down Expand Up @@ -2848,6 +2851,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"`
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -84,6 +85,12 @@ function renderActionCell({ clusterId }: Cluster, flags: MenuFlags) {
);
}

$items.push(<DropdownDivider key="divider" />);

$items.push(
renderMenuItem('Manage Cluster', cfg.getManageClusterRoute(clusterId))
);

return (
<Cell align="right">{$items && <MenuButton children={$items} />}</Cell>
);
Expand Down
4 changes: 2 additions & 2 deletions web/packages/teleport/src/Clusters/Clusters.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,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 {
Expand All @@ -40,7 +40,7 @@ export function Story({ value }: { value: teleport.Context }) {
<teleport.ContextProvider ctx={ctx}>
<FeaturesContextProvider value={getOSSFeatures()}>
<Router history={createMemoryHistory()}>
<Clusters />
<ClusterListPage />
</Router>
</FeaturesContextProvider>
</teleport.ContextProvider>
Expand Down
21 changes: 21 additions & 0 deletions web/packages/teleport/src/Clusters/Clusters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Switch>
<Route
key="cluster-list"
exact
path={cfg.routes.clusters}
component={ClusterListPage}
/>
<Route
key="cluster-management"
path={cfg.routes.manageCluster}
component={ManageCluster}
/>
</Switch>
);
}

export function ClusterListPage() {
const ctx = useTeleport();

const [clusters, setClusters] = useState([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.
*/

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<any>) {
const ctx = createTeleportContext();

ctx.clusterService.fetchClusterDetails = fetchClusterDetails;
return (
<MemoryRouter initialEntries={['/clusters/test-cluster']}>
<Route path="/clusters/:clusterId">
<ContentMinWidth>
<ContextProvider ctx={ctx}>
<ManageCluster />
</ContextProvider>
</ContentMinWidth>
</Route>
</MemoryRouter>
);
}

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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* 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 <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import { MemoryRouter, Route } from 'react-router-dom';
import { setupServer } from 'msw/node';
import { rest } 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(
<MemoryRouter initialEntries={[`/clusters/cluster-id`]}>
<Route path="/clusters/:clusterId">
<ContentMinWidth>
<ContextProvider ctx={ctx}>{element}</ContextProvider>
</ContentMinWidth>
</Route>
</MemoryRouter>
);
}

describe('test ManageCluster component', () => {
const server = setupServer(
rest.get(cfg.getClusterInfoPath('cluster-id'), (req, res, ctx) => {
return res(
ctx.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(<ManageCluster />, 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(
rest.get(cfg.getClusterInfoPath('cluster-id'), (req, res, ctx) => {
return res(
ctx.status(400),
ctx.json({
message: 'Failed to load cluster information',
})
);
})
);

const ctx = createTeleportContext();

renderElement(<ManageCluster />, ctx);
await waitFor(() => {
expect(
screen.getByText('Failed to load cluster information')
).toBeInTheDocument();
});

await waitFor(() => {
expect(
screen.queryByText(clusterInfoFixture.authVersion)
).not.toBeInTheDocument();
});
});
});
Loading

0 comments on commit cc15bef

Please sign in to comment.