diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index cd0fd426c9d59..55a46f8d54bb0 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -811,6 +811,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))
@@ -2882,6 +2885,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 03fd1184c931f..899ba8de53a90 100644
--- a/web/packages/teleport/src/Clusters/Clusters.story.tsx
+++ b/web/packages/teleport/src/Clusters/Clusters.story.tsx
@@ -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 {
@@ -40,7 +40,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 61c95d919f05a..68f4acac1fe6f 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 11befae58598c..2f1478017633d 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -156,6 +156,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',
@@ -217,6 +219,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?`,
@@ -418,6 +421,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,
@@ -533,6 +542,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 722d97fb565e3..b7e563c36b89b 100644
--- a/web/packages/teleport/src/features.tsx
+++ b/web/packages/teleport/src/features.tsx
@@ -571,6 +571,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;