diff --git a/web/packages/teleport/src/Apps/AddApp/useAddApp.ts b/web/packages/teleport/src/Apps/AddApp/useAddApp.ts
index 9fda112960251..94143f9c5662c 100644
--- a/web/packages/teleport/src/Apps/AddApp/useAddApp.ts
+++ b/web/packages/teleport/src/Apps/AddApp/useAddApp.ts
@@ -36,7 +36,7 @@ export default function useAddApp(ctx: TeleportContext) {
function createToken() {
return run(() =>
- ctx.joinTokenService.fetchJoinToken(['App']).then(setToken)
+ ctx.joinTokenService.fetchJoinToken({ roles: ['App'] }).then(setToken)
);
}
diff --git a/web/packages/teleport/src/Databases/AddDatabase/useAddDatabase.ts b/web/packages/teleport/src/Databases/AddDatabase/useAddDatabase.ts
index 57bf974c9463c..12fc154f733c5 100644
--- a/web/packages/teleport/src/Databases/AddDatabase/useAddDatabase.ts
+++ b/web/packages/teleport/src/Databases/AddDatabase/useAddDatabase.ts
@@ -31,7 +31,7 @@ export default function useAddDatabase(ctx: TeleportContext) {
function createJoinToken() {
return run(() =>
- ctx.joinTokenService.fetchJoinToken(['Db']).then(setToken)
+ ctx.joinTokenService.fetchJoinToken({ roles: ['Db'] }).then(setToken)
);
}
diff --git a/web/packages/teleport/src/Discover/Kubernetes/DownloadScript/DownloadScript.story.tsx b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx
similarity index 89%
rename from web/packages/teleport/src/Discover/Kubernetes/DownloadScript/DownloadScript.story.tsx
rename to web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx
index aa88d290e86bf..2ccf7157bf69b 100644
--- a/web/packages/teleport/src/Discover/Kubernetes/DownloadScript/DownloadScript.story.tsx
+++ b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.story.tsx
@@ -29,20 +29,29 @@ import { userContext } from 'teleport/mocks/contexts';
import DownloadScript from './DownloadScript';
+const { worker, rest } = window.msw;
+
export default {
- title: 'Teleport/Discover/Kube/DownloadScripts',
+ title: 'Teleport/Discover/Database/DownloadScript',
decorators: [
Story => {
// Reset request handlers added in individual stories.
- window.msw.worker.resetHandlers();
+ worker.resetHandlers();
clearCachedJoinTokenResult();
return ;
},
],
};
+export const Init = () => {
+ return (
+
+
+
+ );
+};
+
export const Polling = () => {
- const { worker, rest } = window.msw;
// Use default fetch token handler defined in mocks/handlers
worker.use(
@@ -51,14 +60,13 @@ export const Polling = () => {
})
);
return (
-
+
);
};
export const PollingSuccess = () => {
- const { worker, rest } = window.msw;
// Use default fetch token handler defined in mocks/handlers
worker.use(
@@ -67,14 +75,13 @@ export const PollingSuccess = () => {
})
);
return (
-
+
);
};
export const PollingError = () => {
- const { worker, rest } = window.msw;
// Use default fetch token handler defined in mocks/handlers
worker.use(
@@ -83,27 +90,25 @@ export const PollingError = () => {
})
);
return (
-
+
);
};
export const Processing = () => {
- const { worker, rest } = window.msw;
worker.use(
rest.post(cfg.api.joinTokenPath, (req, res, ctx) => {
return res(ctx.delay('infinite'));
})
);
return (
-
+
);
};
export const Failed = () => {
- const { worker, rest } = window.msw;
worker.use(
rest.post(cfg.api.joinTokenPath, (req, res, ctx) => {
return res.once(ctx.status(500));
@@ -125,7 +130,7 @@ const Provider = props => {
{props.children}
diff --git a/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx
new file mode 100644
index 0000000000000..34e2313a38433
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Database/DownloadScript/DownloadScript.tsx
@@ -0,0 +1,373 @@
+/**
+ * Copyright 2022 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, Suspense } from 'react';
+import {
+ Text,
+ Box,
+ ButtonSecondary,
+ Flex,
+ ButtonIcon,
+ ButtonText,
+} from 'design';
+import * as Icons from 'design/Icon';
+import FieldInput from 'shared/components/FieldInput';
+import Validation, {
+ useValidation,
+ Validator,
+} from 'shared/components/Validation';
+import { requiredField } from 'shared/components/Validation/rules';
+
+import { CatchError } from 'teleport/components/CatchError';
+import {
+ useJoinToken,
+ clearCachedJoinTokenResult,
+} from 'teleport/Discover/Shared/JoinTokenContext';
+import { usePingTeleport } from 'teleport/Discover/Shared/PingTeleportContext';
+import { CommandWithTimer } from 'teleport/Discover/Shared/CommandWithTimer';
+import { AgentLabel } from 'teleport/services/agents';
+import cfg from 'teleport/config';
+import { Database } from 'teleport/services/databases';
+
+import {
+ ActionButtons,
+ Header,
+ HeaderSubtitle,
+ ResourceKind,
+ TextIcon,
+} from '../../Shared';
+
+import type { AgentStepProps } from '../../types';
+import type { Poll } from 'teleport/Discover/Shared/CommandWithTimer';
+
+export default function Container(
+ props: AgentStepProps & { runJoinTokenPromise?: boolean }
+) {
+ const [showScript, setShowScript] = useState(props.runJoinTokenPromise);
+ const [labels, setLabels] = useState([
+ { name: '*', value: '*' },
+ ]);
+
+ function handleGenerateCommand(validator: Validator) {
+ if (!validator.validate()) {
+ return;
+ }
+
+ setShowScript(true);
+ }
+
+ return (
+
+ {({ validator }) => (
+ (
+
+
+
+
+ Generate Command
+
+
+
+
+ Encountered Error: {props.error.message}
+
+
+ null} disableProceed={true} />
+
+ )}
+ >
+
+
+
+
+ Generate Command
+
+ null} disableProceed={true} />
+
+ }
+ >
+ {!showScript && (
+
+
+
+ handleGenerateCommand(validator)}
+ >
+ Generate Command
+
+ null} disableProceed={true} />
+
+ )}
+ {showScript && (
+
+ )}
+
+
+ )}
+
+ );
+}
+
+export function DownloadScript(
+ props: AgentStepProps & {
+ labels: AgentLabel[];
+ setLabels(l: AgentLabel[]): void;
+ }
+) {
+ // Fetches join token.
+ const { joinToken, reloadJoinToken, timeout } = useJoinToken(
+ ResourceKind.Database,
+ props.labels
+ );
+
+ // Starts resource querying interval.
+ const {
+ timedOut: pollingTimedOut,
+ start,
+ result,
+ } = usePingTeleport();
+
+ function regenerateScriptAndRepoll() {
+ reloadJoinToken();
+ start();
+ }
+
+ function handleNextStep() {
+ props.updateAgentMeta({
+ ...props.agentMeta,
+ resourceName: result.name,
+ db: result,
+ });
+ }
+
+ let poll: Poll = { state: 'polling' };
+ if (pollingTimedOut) {
+ poll = {
+ state: 'error',
+ error: {
+ reasonContents: [
+ <>
+ The command was not run on the server you were trying to add,
+ regenerate command and try again.
+ >,
+ // TODO (lisa): not sure what this message should be.
+ // <>
+ // The Teleport Service could not join this Teleport cluster. Check the
+ // logs for errors by running
+ // kubectl logs -l app=teleport-agent -n {namespace}
+ // >,
+ ],
+ },
+ };
+ } else if (result) {
+ poll = { state: 'success' };
+ }
+
+ return (
+
+
+
+
+ Regenerate Command
+
+
+
+
+ );
+}
+
+const Heading = () => {
+ return (
+ <>
+ Deploy a Database Service
+ TODO lorem ipsum dolores
+ >
+ );
+};
+
+export const Labels = ({
+ labels,
+ setLabels,
+ disableAddBtn = false,
+}: {
+ labels: AgentLabel[];
+ setLabels(l: AgentLabel[]): void;
+ disableAddBtn?: boolean;
+}) => {
+ const validator = useValidation() as Validator;
+
+ function addLabel() {
+ // Prevent adding more rows if there are
+ // empty input fields. After checking,
+ // reset the validator so the newly
+ // added empty input boxes are not
+ // considered an error.
+ if (!validator.validate()) {
+ return;
+ }
+ validator.reset();
+ setLabels([...labels, { name: '', value: '' }]);
+ }
+
+ function removeLabel(index: number) {
+ if (labels.length === 1) {
+ // Since at least one label is required
+ // instead of removing the row, clear
+ // the input and turn on error.
+ const newList = [...labels];
+ newList[index] = { name: '', value: '' };
+ setLabels(newList);
+
+ validator.validate();
+ return;
+ }
+ const newList = [...labels];
+ newList.splice(index, 1);
+ setLabels(newList);
+ }
+
+ const handleChange = (
+ event: React.ChangeEvent,
+ index: number,
+ labelField: keyof AgentLabel
+ ) => {
+ const { value } = event.target;
+ const newList = [...labels];
+ newList[index] = { ...newList[index], [labelField]: value };
+ setLabels(newList);
+ };
+
+ return (
+
+ Labels
+
+ At least one label is required to help this service identify your
+ database.
+
+
+
+ Key{' '}
+
+ (required field)
+
+
+
+ Value{' '}
+
+ (required field)
+
+
+
+
+ {labels.map((label, index) => {
+ return (
+
+
+ handleChange(e, index, 'name')}
+ />
+ handleChange(e, index, 'value')}
+ />
+ removeLabel(index)}
+ >
+
+
+
+
+ );
+ })}
+
+
+
+ Add New Label
+
+
+ );
+};
+
+function createBashCommand(tokenId: string) {
+ return `sudo bash -c "$(curl -fsSL ${cfg.getDbScriptUrl(tokenId)})"`;
+}
diff --git a/web/packages/teleport/src/Discover/Database/DownloadScript/index.ts b/web/packages/teleport/src/Discover/Database/DownloadScript/index.ts
new file mode 100644
index 0000000000000..f8066fcc9c8d2
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Database/DownloadScript/index.ts
@@ -0,0 +1,17 @@
+/**
+ * Copyright 2022 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 { DownloadScript } from './DownloadScript';
diff --git a/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx
new file mode 100644
index 0000000000000..00b101f6c9294
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.story.tsx
@@ -0,0 +1,152 @@
+/**
+ * Copyright 2022 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 { MemoryRouter } from 'react-router';
+
+import { ContextProvider, Context as TeleportContext } from 'teleport';
+import cfg from 'teleport/config';
+import { ResourceKind } from 'teleport/Discover/Shared';
+import {
+ clearCachedJoinTokenResult,
+ JoinTokenProvider,
+} from 'teleport/Discover/Shared/JoinTokenContext';
+import { PingTeleportProvider } from 'teleport/Discover/Shared/PingTeleportContext';
+import { userContext } from 'teleport/mocks/contexts';
+
+import HelmChart from './HelmChart';
+
+const { worker, rest } = window.msw;
+
+export default {
+ title: 'Teleport/Discover/Kube/HelmChart',
+ decorators: [
+ Story => {
+ // Reset request handlers added in individual stories.
+ worker.resetHandlers();
+ clearCachedJoinTokenResult();
+ return ;
+ },
+ ],
+};
+
+export const Init = () => {
+ return (
+
+
+
+ );
+};
+
+export const Polling = () => {
+ // Use default fetch token handler defined in mocks/handlers
+
+ worker.use(
+ rest.get(cfg.api.kubernetesPath, (req, res, ctx) => {
+ return res(ctx.delay('infinite'));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const PollingSuccess = () => {
+ // Use default fetch token handler defined in mocks/handlers
+
+ worker.use(
+ rest.get(cfg.api.kubernetesPath, (req, res, ctx) => {
+ return res(ctx.json({ items: [{}] }));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const PollingError = () => {
+ // Use default fetch token handler defined in mocks/handlers
+
+ worker.use(
+ rest.get(cfg.api.kubernetesPath, (req, res, ctx) => {
+ return res(ctx.delay('infinite'));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const Processing = () => {
+ worker.use(
+ rest.post(cfg.api.joinTokenPath, (req, res, ctx) => {
+ return res(ctx.delay('infinite'));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const Failed = () => {
+ worker.use(
+ rest.post(cfg.api.joinTokenPath, (req, res, ctx) => {
+ return res.once(ctx.status(500));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+const Provider = props => {
+ const ctx = createTeleportContext();
+
+ return (
+
+
+
+
+ {props.children}
+
+
+
+
+ );
+};
+
+function createTeleportContext() {
+ const ctx = new TeleportContext();
+
+ ctx.isEnterprise = false;
+ ctx.storeUser.setState(userContext);
+
+ return ctx;
+}
diff --git a/web/packages/teleport/src/Discover/Kubernetes/DownloadScript/DownloadScript.tsx b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx
similarity index 64%
rename from web/packages/teleport/src/Discover/Kubernetes/DownloadScript/DownloadScript.tsx
rename to web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx
index 39c1a19aa49fe..3dab0dfb51950 100644
--- a/web/packages/teleport/src/Discover/Kubernetes/DownloadScript/DownloadScript.tsx
+++ b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/HelmChart.tsx
@@ -28,10 +28,9 @@ import {
useJoinToken,
clearCachedJoinTokenResult,
} from 'teleport/Discover/Shared/JoinTokenContext';
-import { Timeout } from 'teleport/Discover/Shared/Timeout';
import useTeleport from 'teleport/useTeleport';
import { usePingTeleport } from 'teleport/Discover/Shared/PingTeleportContext';
-import { PollBox, PollState } from 'teleport/Discover/Shared/PollState';
+import { CommandWithTimer } from 'teleport/Discover/Shared/CommandWithTimer';
import {
ActionButtons,
@@ -46,12 +45,14 @@ import type { AgentStepProps } from '../../types';
import type { JoinToken } from 'teleport/services/joinToken';
import type { AgentMeta, KubeMeta } from 'teleport/Discover/useDiscover';
import type { Kube } from 'teleport/services/kube';
+import type { Poll } from 'teleport/Discover/Shared/CommandWithTimer';
export default function Container(
props: AgentStepProps & { runJoinTokenPromise?: boolean }
) {
const [namespace, setNamespace] = useState('');
const [clusterName, setClusterName] = useState('');
+ const [showHelmChart, setShowHelmChart] = useState(props.runJoinTokenPromise);
return (
// This outer CatchError and Suspense handles
@@ -89,91 +90,66 @@ export default function Container(
}
>
-
+ {!showHelmChart && (
+
+
+
+ setShowHelmChart(true)}
+ namespace={namespace}
+ setNamespace={setNamespace}
+ clusterName={clusterName}
+ setClusterName={setClusterName}
+ />
+ null} disableProceed={true} />
+
+ )}
+ {showHelmChart && (
+
+ )}
);
}
-export function DownloadScript(
+export function HelmChart(
props: AgentStepProps & {
namespace: string;
setNamespace(n: string): void;
clusterName: string;
setClusterName(c: string): void;
- // runJoinTokenPromise is used only for development
- // (eg. stories) to bypass user input requirement.
- runJoinTokenPromise?: boolean;
}
) {
- const { runJoinTokenPromise = false } = props;
- // runPromise is a flag that when false prevents the `useJoinToken` hook
- // from running on the first run. If we let this run in the background on first run,
- // and it returns an error, the error is caught by the CatchError
- // component which will interrupt the user (rendering the failed state)
- // without the user clicking on any buttons. After the user fills out required fields
- // and clicks on `handleSubmit`, this flag will always be true.
- const [runPromise, setRunPromise] = useState(runJoinTokenPromise);
- const [showHelmChart, setShowHelmChart] = useState(false);
- const {
- joinToken,
- reloadJoinToken: fetchJoinToken,
- timeout,
- } = useJoinToken(ResourceKind.Kubernetes, runPromise);
-
- function handleSubmit(validator: Validator) {
- if (!validator.validate()) {
- return;
- }
- setRunPromise(true);
- }
-
- function reloadJoinToken(validator: Validator) {
- if (!validator.validate()) {
- return;
- }
- fetchJoinToken();
-
- // Hide chart until we finished fetching a new join token.
- setShowHelmChart(false);
- }
-
- React.useEffect(() => {
- if (joinToken) {
- setShowHelmChart(true);
- }
- }, [joinToken]);
+ const { joinToken, reloadJoinToken, timeout } = useJoinToken(
+ ResourceKind.Kubernetes
+ );
return (
(joinToken ? reloadJoinToken(v) : handleSubmit(v))}
+ generateScript={reloadJoinToken}
namespace={props.namespace}
setNamespace={props.setNamespace}
clusterName={props.clusterName}
setClusterName={props.setClusterName}
hasJoinToken={!!joinToken}
/>
- {showHelmChart ? (
-
- ) : (
- null} disableProceed={true} />
- )}
+
);
}
@@ -218,7 +194,6 @@ const StepOne = () => {
};
const StepTwo = ({
- handleSubmit,
namespace,
setNamespace,
clusterName,
@@ -226,16 +201,23 @@ const StepTwo = ({
hasJoinToken,
error,
onRetry,
+ generateScript,
}: {
error?: Error;
onRetry?(): void;
- handleSubmit?(v: Validator): void;
+ generateScript?(): void;
namespace: string;
setNamespace(n: string): void;
clusterName: string;
setClusterName(c: string): void;
hasJoinToken?: boolean;
}) => {
+ function handleSubmit(validator: Validator) {
+ if (!validator.validate()) {
+ return;
+ }
+ generateScript();
+ }
return (
Step 2
@@ -273,7 +255,7 @@ const StepTwo = ({
width="200px"
type="submit"
// Let user re-try on error
- disabled={!error && !handleSubmit}
+ disabled={!error && !generateScript}
onClick={() => (error ? onRetry() : handleSubmit(validator))}
>
{hasJoinToken ? 'Regenerate Command' : 'Generate Command'}
@@ -338,11 +320,26 @@ const InstallHelmChart = ({
// Starts resource querying interval.
const { timedOut: pollingTimedOut, result } = usePingTeleport();
- let pollState: PollState = 'polling';
+ let poll: Poll = { state: 'polling' };
if (pollingTimedOut) {
- pollState = 'error';
+ poll = {
+ state: 'error',
+ error: {
+ reasonContents: [
+ <>
+ The command was not run on the server you were trying to add,
+ regenerate command and try again.
+ >,
+ <>
+ The Teleport Service could not join this Teleport cluster. Check the
+ logs for errors by running
+ kubectl logs -l app=teleport-agent -n {namespace}
+ >,
+ ],
+ },
+ };
} else if (result) {
- pollState = 'success';
+ poll = { state: 'success' };
}
function handleOnProceed() {
@@ -356,88 +353,36 @@ const InstallHelmChart = ({
return (
<>
-
- Step 3
-
- Run the command below on the server running your Kubernetes cluster.
- May take up to a minute for the Teleport Service to join after running
- the command.
-
-
-
-
- {pollState === 'polling' && (
-
-
-
-
- )}
- {pollState === 'success' && (
-
-
- The Teleport Service successfully join this Teleport cluster
-
- )}
- {pollState === 'error' && }
-
+
+ Step 3
+
+ Run the command below on the server running your Kubernetes
+ cluster. May take up to a minute for the Teleport Service to join
+ after running the command.
+
+ >
+ }
+ />
>
);
};
-const TimeoutError = ({ namespace }: { namespace: string }) => {
- return (
-
-
-
- We could not detect the Teleport Service you were trying to add
-
-
- Possible reasons
-
-
-
- The command was not run on the server you were trying to add,
- regenerate command and try again.
-
-
- The Teleport Service could not join this Teleport cluster. Check the
- logs for errors by running
- kubectl logs -l app=teleport-agent -n {namespace}
-
-
-
- );
-};
-
const StyledBox = styled(Box)`
max-width: 800px;
background-color: rgba(255, 255, 255, 0.05);
diff --git a/web/packages/teleport/src/Discover/Kubernetes/DownloadScript/index.ts b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/index.ts
similarity index 89%
rename from web/packages/teleport/src/Discover/Kubernetes/DownloadScript/index.ts
rename to web/packages/teleport/src/Discover/Kubernetes/HelmChart/index.ts
index d1a2634637f28..7aec926ba123d 100644
--- a/web/packages/teleport/src/Discover/Kubernetes/DownloadScript/index.ts
+++ b/web/packages/teleport/src/Discover/Kubernetes/HelmChart/index.ts
@@ -14,6 +14,6 @@
* limitations under the License.
*/
-import DownloadScript from './DownloadScript';
+import HelmChart from './HelmChart';
-export { DownloadScript };
+export { HelmChart };
diff --git a/web/packages/teleport/src/Discover/Kubernetes/index.tsx b/web/packages/teleport/src/Discover/Kubernetes/index.tsx
index 92ab4e57d7842..4596ad6d8a301 100644
--- a/web/packages/teleport/src/Discover/Kubernetes/index.tsx
+++ b/web/packages/teleport/src/Discover/Kubernetes/index.tsx
@@ -22,8 +22,8 @@ import { Finished, ResourceKind } from 'teleport/Discover/Shared';
import { Resource } from 'teleport/Discover/flow';
import { KubeWrapper } from './KubeWrapper';
-import { DownloadScript } from './DownloadScript';
import { SetupAccess } from './SetupAccess';
+import { HelmChart } from './HelmChart';
import { TestConnection } from './TestConnection';
export const KubernetesResource: Resource = {
@@ -42,7 +42,7 @@ export const KubernetesResource: Resource = {
},
{
title: 'Configure Resource',
- component: DownloadScript,
+ component: HelmChart,
},
{
title: 'Set Up Access',
diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.test.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.test.tsx
deleted file mode 100644
index f77a56c5f0d8c..0000000000000
--- a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.test.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-/**
- * Copyright 2022 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 { render } from 'design/utils/testing';
-
-import {
- Polling,
- PollingSuccess,
- PollingError,
- Failed,
-} from './DownloadScript.story';
-
-test('polling state', () => {
- const { container } = render();
- expect(container.firstChild).toMatchSnapshot();
-});
-
-test('polling success state', () => {
- const { container } = render();
- expect(container.firstChild).toMatchSnapshot();
-});
-
-test('polling error state', () => {
- const { container } = render();
- expect(container.firstChild).toMatchSnapshot();
-});
-
-test('failed', () => {
- const { container } = render();
- expect(container.firstChild).toMatchSnapshot();
-});
diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx
index d248d95cf0a8c..2662822bec1d1 100644
--- a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx
+++ b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.story.tsx
@@ -15,61 +15,130 @@
*/
import React from 'react';
-
import { MemoryRouter } from 'react-router';
-import { DownloadScript } from './DownloadScript';
-import { State } from './useDownloadScript';
+import { ContextProvider, Context as TeleportContext } from 'teleport';
+import cfg from 'teleport/config';
+import {
+ clearCachedJoinTokenResult,
+ JoinTokenProvider,
+} from 'teleport/Discover/Shared/JoinTokenContext';
+import { PingTeleportProvider } from 'teleport/Discover/Shared/PingTeleportContext';
+import { userContext } from 'teleport/Main/fixtures';
+import { ResourceKind } from 'teleport/Discover/Shared';
+
+import DownloadScript from './DownloadScript';
+
+const { worker, rest } = window.msw;
export default {
- title: 'Teleport/Discover/Server/DownloadScript',
+ title: 'Teleport/Discover/Server/DownloadScripts',
+ decorators: [
+ Story => {
+ // Reset request handlers added in individual stories.
+ worker.resetHandlers();
+ clearCachedJoinTokenResult();
+ return ;
+ },
+ ],
+};
+
+export const Polling = () => {
+ // Use default fetch token handler defined in mocks/handlers
+
+ worker.use(
+ rest.get(cfg.api.nodesPath, (req, res, ctx) => {
+ return res(ctx.delay('infinite'));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const PollingSuccess = () => {
+ // Use default fetch token handler defined in mocks/handlers
+
+ worker.use(
+ rest.get(cfg.api.nodesPath, (req, res, ctx) => {
+ return res(ctx.json({ items: [{}] }));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const PollingError = () => {
+ // Use default fetch token handler defined in mocks/handlers
+
+ worker.use(
+ rest.get(cfg.api.nodesPath, (req, res, ctx) => {
+ return res(ctx.delay('infinite'));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const Processing = () => {
+ worker.use(
+ rest.post(cfg.api.joinTokenPath, (req, res, ctx) => {
+ return res(ctx.delay('infinite'));
+ })
+ );
+ return (
+
+
+
+ );
+};
+
+export const Failed = () => {
+ worker.use(
+ rest.post(cfg.api.joinTokenPath, (req, res, ctx) => {
+ return res.once(ctx.status(500));
+ })
+ );
+ return (
+
+
+
+ );
};
-export const Polling = () => (
-
-
-
-);
-
-export const PollingSuccess = () => (
-
-
-
-);
-
-export const PollingError = () => (
-
-
-
-);
-
-export const Processing = () => (
-
-
-
-);
-
-export const Failed = () => (
-
-
-
-);
-
-const props: State = {
- attempt: {
- status: 'success',
- statusText: '',
- },
- pollState: 'polling',
- nextStep: () => null,
- joinToken: {
- id: 'some-join-token-hash',
- expiryText: '4 hours',
- expiry: new Date(),
- },
- regenerateScriptAndRepoll: () => null,
- countdownTime: { minutes: 5, seconds: 0 },
+const Provider = props => {
+ const ctx = createTeleportContext();
+
+ return (
+
+
+
+
+ {props.children}
+
+
+
+
+ );
};
+
+function createTeleportContext() {
+ const ctx = new TeleportContext();
+
+ ctx.isEnterprise = false;
+ ctx.storeUser.setState(userContext);
+
+ return ctx;
+}
diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx
index 0a513f8bae417..be288fcebda05 100644
--- a/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx
+++ b/web/packages/teleport/src/Discover/Server/DownloadScript/DownloadScript.tsx
@@ -14,188 +14,185 @@
* limitations under the License.
*/
-import React from 'react';
-import styled from 'styled-components';
-import { Text, Box, Indicator } from 'design';
+import React, { Suspense } from 'react';
+import { Box, Indicator } from 'design';
import * as Icons from 'design/Icon';
import cfg from 'teleport/config';
-import TextSelectCopy from 'teleport/components/TextSelectCopy';
-import useTeleport from 'teleport/useTeleport';
+import { CatchError } from 'teleport/components/CatchError';
+import {
+ useJoinToken,
+ clearCachedJoinTokenResult,
+} from 'teleport/Discover/Shared/JoinTokenContext';
+import { usePingTeleport } from 'teleport/Discover/Shared/PingTeleportContext';
+import { JoinToken } from 'teleport/services/joinToken';
+import { CommandWithTimer } from 'teleport/Discover/Shared/CommandWithTimer';
+import {
+ PollBox,
+ PollState,
+} from 'teleport/Discover/Shared/CommandWithTimer/CommandWithTimer';
import { AgentStepProps } from '../../types';
-
import {
ActionButtons,
ButtonBlueText,
Header,
HeaderSubtitle,
Mark,
+ ResourceKind,
TextIcon,
} from '../../Shared';
-import { useDownloadScript } from './useDownloadScript';
-
-import type { State, CountdownTime } from './useDownloadScript';
+import type { Node } from 'teleport/services/nodes';
+import type { Poll } from 'teleport/Discover/Shared/CommandWithTimer';
export default function Container(props: AgentStepProps) {
- const ctx = useTeleport();
- const state = useDownloadScript({ ctx, props });
+ return (
+ (
+ null}>
+
+
+ Encountered Error: {props.error.message}
+
+
+ Refetch a command
+
+
+ )}
+ >
+
+ null}>
+
+
+
+
+
+ }
+ >
+
+
+
+ );
+}
+
+export function DownloadScript(props: AgentStepProps) {
+ // Fetches join token.
+ const { joinToken, reloadJoinToken, timeout } = useJoinToken(
+ ResourceKind.Server
+ );
+ // Starts resource querying interval.
+ const { timedOut: pollingTimedOut, start, result } = usePingTeleport();
+
+ function regenerateScriptAndRepoll() {
+ reloadJoinToken();
+ start();
+ }
- return ;
+ let poll: Poll = {
+ state: 'polling',
+ customStateDesc: 'Waiting for Teleport SSH Service',
+ };
+ if (pollingTimedOut) {
+ poll = {
+ state: 'error',
+ error: {
+ customErrContent: (
+ <>
+ We could not detect the server you were trying to add.{' '}
+
+ Generate a new command
+
+ >
+ ),
+ reasonContents: [
+ <>The command was not run on the server you were trying to add>,
+ <>
+ The Teleport SSH Service could not join this Teleport cluster. Check
+ the logs for errors by running
+ journalctl status teleport
+ >,
+ ],
+ },
+ };
+ } else if (result) {
+ poll = {
+ state: 'success',
+ customStateDesc: 'The server successfully joined this Teleport cluster',
+ };
+ }
+
+ function handleNextStep() {
+ props.updateAgentMeta({
+ ...props.agentMeta,
+ // Node is an oddity in that the hostname is the more
+ // user identifiable resource name and what user expects
+ // as the resource name.
+ resourceName: result.hostname,
+ node: result,
+ });
+ }
+
+ return (
+ <>
+ Configure Resource
+
+ Install and configure the Teleport SSH Service.
+
+ Run the following command on the server you want to add.
+
+
+
+ >
+ );
}
-export function DownloadScript({
- attempt,
- joinToken,
+const Template = ({
nextStep,
pollState,
- regenerateScriptAndRepoll,
- countdownTime,
-}: State) {
+ children,
+}: {
+ nextStep(): void;
+ pollState?: PollState;
+ children: React.ReactNode;
+}) => {
return (
-
+ <>
Configure Resource
Install and configure the Teleport SSH Service.
Run the following command on the server you want to add.
-
- Command
- {attempt.status === 'processing' && (
-
-
-
- )}
- {attempt.status === 'failed' && (
- <>
-
-
- Encountered Error: {attempt.statusText}
-
-
- Refetch a command
-
- >
- )}
- {attempt.status === 'success' && (
- <>
-
- {pollState === 'polling' && (
-
-
- {`Waiting for Teleport SSH Service | ${formatTime(
- countdownTime
- )}`}
-
- )}
- {pollState === 'success' && (
-
-
- The server successfully joined this Teleport cluster
-
- )}
- {pollState === 'error' && (
-
- )}
- >
- )}
-
+ {children}
-
+ >
);
-}
+};
function createBashCommand(tokenId: string) {
return `sudo bash -c "$(curl -fsSL ${cfg.getNodeScriptUrl(tokenId)})"`;
}
-function formatTime({ minutes, seconds }: CountdownTime) {
- const formattedSeconds = String(seconds).padStart(2, '0');
- const formattedMinutes = String(minutes).padStart(2, '0');
-
- let timeNotation = 'minute';
- if (!minutes && seconds >= 0) {
- timeNotation = 'seconds';
- }
- if (minutes) {
- timeNotation = 'minutes';
- }
-
- return `${formattedMinutes}:${formattedSeconds} ${timeNotation}`;
-}
-
-const ScriptBox = styled(Box)`
- max-width: 800px;
- background-color: rgba(255, 255, 255, 0.05);
- border: 2px solid
- ${props => {
- switch (props.pollState) {
- case 'error':
- return props.theme.colors.danger;
- case 'success':
- return props.theme.colors.success;
- default:
- // polling
- return '#2F3659';
- }
- }};
-`;
-
-const TimeoutError = ({
- regenerateScriptAndRepoll,
-}: {
+export type State = {
+ joinToken: JoinToken;
+ nextStep(): void;
regenerateScriptAndRepoll(): void;
-}) => {
- return (
-
-
-
- We could not detect the server you were trying to add{' '}
-
- Generate a new command
-
-
-
- Possible reasons
-
-
-
The command was not run on the server you were trying to add
-
- The Teleport SSH Service could not join this Teleport cluster. Check
- the logs for errors by running
- journalctl status teleport
-
-`;
diff --git a/web/packages/teleport/src/Discover/Server/DownloadScript/useDownloadScript.tsx b/web/packages/teleport/src/Discover/Server/DownloadScript/useDownloadScript.tsx
deleted file mode 100644
index 398ba84815fb8..0000000000000
--- a/web/packages/teleport/src/Discover/Server/DownloadScript/useDownloadScript.tsx
+++ /dev/null
@@ -1,199 +0,0 @@
-/**
- * Copyright 2022 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 { useState, useEffect } from 'react';
-import {
- addMinutes,
- intervalToDuration,
- differenceInMilliseconds,
-} from 'date-fns';
-import useAttempt from 'shared/hooks/useAttemptNext';
-
-import { INTERNAL_RESOURCE_ID_LABEL_KEY } from 'teleport/services/joinToken';
-import TeleportContext from 'teleport/teleportContext';
-
-import { AgentStepProps } from '../../types';
-
-import type { JoinToken } from 'teleport/services/joinToken';
-
-const FIVE_MINUTES = 5;
-const THREE_SECONDS_IN_MS = 3000;
-const ONE_SECOND_IN_MS = 1000;
-
-export function useDownloadScript({ ctx, props }: Props) {
- const { attempt, run, setAttempt } = useAttempt('processing');
- const [joinToken, setJoinToken] = useState();
- const [pollState, setPollState] = useState('polling');
-
- // TODO (lisa) extract count down logic into it's own component.
- const [countdownTime, setCountdownTime] = useState({
- minutes: 5,
- seconds: 0,
- });
-
- // Responsible for initial join token fetch.
- useEffect(() => {
- fetchJoinToken();
- }, []);
-
- // Responsible for initiating polling and
- // timeout on join token change.
- useEffect(() => {
- if (!joinToken) return;
-
- setPollState('polling');
-
- // abortController is required to cancel in flight request.
- const abortController = new AbortController();
- const abortSignal = abortController.signal;
- let timeoutId;
- let pollingIntervalId;
- let countdownIntervalId;
-
- // countdownEndDate takes current date and adds 5 minutes to it.
- let countdownEndDate = addMinutes(new Date(), FIVE_MINUTES);
-
- // inFlightReq is a flag to prevent another fetch request when a
- // previous fetch request is still in progress. May happen when a
- // previous fetch request is taking longer than the polling
- // interval time.
- let inFlightReq;
-
- function cleanUp() {
- clearInterval(pollingIntervalId);
- clearInterval(countdownIntervalId);
- clearTimeout(timeoutId);
- setCountdownTime({ minutes: 5, seconds: 0 });
- // Cancel any in flight request.
- abortController.abort();
- }
-
- function fetchNodeMatchingRefResourceId() {
- if (inFlightReq) return;
-
- inFlightReq = ctx.nodeService
- .fetchNodes(
- ctx.storeUser.getClusterId(),
- {
- search: `${INTERNAL_RESOURCE_ID_LABEL_KEY} ${joinToken.internalResourceId}`,
- limit: 1,
- },
- abortSignal
- )
- .then(res => {
- if (res.agents.length > 0) {
- setPollState('success');
- props.updateAgentMeta({
- ...props.agentMeta,
- // Node is an oddity in that the hostname is the more
- // user friendly text.
- resourceName: res.agents[0].hostname,
- node: res.agents[0],
- });
- cleanUp();
- }
- })
- // Polling related errors are ignored.
- // The most likely cause of error would be network issues
- // and aborting in flight request.
- .catch(() => {})
- .finally(() => {
- inFlightReq = null; // reset flag
- });
- }
-
- function updateCountdown() {
- const start = new Date();
- const end = countdownEndDate;
- const duration = intervalToDuration({ start, end });
-
- if (differenceInMilliseconds(end, start) <= 0) {
- setPollState('error');
- cleanUp();
- return;
- }
-
- setCountdownTime({
- minutes: duration.minutes,
- seconds: duration.seconds,
- });
- }
-
- // Set a countdown in case polling continuosly produces
- // no results. Which means there is either a network error,
- // script is ran unsuccessfully, script has not been ran,
- // or resource cannot connect to cluster.
- countdownIntervalId = setInterval(
- () => updateCountdown(),
- ONE_SECOND_IN_MS
- );
-
- // Start the poller to discover the resource just added.
- pollingIntervalId = setInterval(
- () => fetchNodeMatchingRefResourceId(),
- THREE_SECONDS_IN_MS
- );
-
- return () => {
- cleanUp();
- };
- }, [joinToken]);
-
- function fetchJoinToken() {
- run(() =>
- ctx.joinTokenService.fetchJoinToken(['Node'], 'token').then(token => {
- // Probably will never happen, but just in case, otherwise
- // querying for the resource can return a false positive.
- if (!token.internalResourceId) {
- setAttempt({
- status: 'failed',
- statusText:
- 'internal resource ID is required to discover the newly added resource, but none was provided',
- });
- return;
- }
- setJoinToken(token);
- })
- );
- }
-
- function regenerateScriptAndRepoll() {
- fetchJoinToken();
- }
-
- return {
- attempt,
- joinToken,
- nextStep: props.nextStep,
- pollState,
- regenerateScriptAndRepoll,
- countdownTime,
- };
-}
-
-type Props = {
- ctx: TeleportContext;
- props: AgentStepProps;
-};
-
-type PollState = 'polling' | 'success' | 'error';
-
-export type CountdownTime = {
- minutes: number;
- seconds: number;
-};
-
-export type State = ReturnType;
diff --git a/web/packages/teleport/src/Discover/Server/ServerWrapper.tsx b/web/packages/teleport/src/Discover/Server/ServerWrapper.tsx
new file mode 100644
index 0000000000000..8dc17fd6964f7
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Server/ServerWrapper.tsx
@@ -0,0 +1,44 @@
+/**
+ * Copyright 2022 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 { PingTeleportProvider } from 'teleport/Discover/Shared/PingTeleportContext';
+import { JoinTokenProvider } from 'teleport/Discover/Shared/JoinTokenContext';
+
+import { ResourceKind } from '../Shared';
+
+const PING_TIMEOUT = 1000 * 60 * 5; // 5 minutes
+const PING_INTERVAL = 1000 * 3; // 3 seconds
+export const SCRIPT_TIMEOUT = 1000 * 60 * 5; // 5 minutes
+
+export function ServerWrapper(props: ServerWrapperProps) {
+ return (
+
+
+ {props.children}
+
+
+ );
+}
+
+interface ServerWrapperProps {
+ children: React.ReactNode;
+}
diff --git a/web/packages/teleport/src/Discover/Server/SetupAccess/index.ts b/web/packages/teleport/src/Discover/Server/SetupAccess/index.ts
index 3f7016de4273d..b93f097a21046 100644
--- a/web/packages/teleport/src/Discover/Server/SetupAccess/index.ts
+++ b/web/packages/teleport/src/Discover/Server/SetupAccess/index.ts
@@ -14,5 +14,5 @@
* limitations under the License.
*/
-import LoginTrait from './SetupAccess';
-export { LoginTrait };
+import SetupAccess from './SetupAccess';
+export { SetupAccess };
diff --git a/web/packages/teleport/src/Discover/Server/index.tsx b/web/packages/teleport/src/Discover/Server/index.tsx
index 98b691407b158..3f2b1ca7d1d50 100644
--- a/web/packages/teleport/src/Discover/Server/index.tsx
+++ b/web/packages/teleport/src/Discover/Server/index.tsx
@@ -20,13 +20,18 @@ import { Server } from 'design/Icon';
import { Resource } from 'teleport/Discover/flow';
import { DownloadScript } from 'teleport/Discover/Server/DownloadScript';
-import { LoginTrait } from 'teleport/Discover/Server/SetupAccess';
+import { SetupAccess } from 'teleport/Discover/Server/SetupAccess';
import { TestConnection } from 'teleport/Discover/Server/TestConnection';
import { ResourceKind, Finished } from 'teleport/Discover/Shared';
+import { ServerWrapper } from './ServerWrapper';
+
export const ServerResource: Resource = {
kind: ResourceKind.Server,
icon: ,
+ wrapper: (component: React.ReactNode) => (
+ {component}
+ ),
shouldPrompt(currentStep) {
// do not prompt on exit if they're selecting a resource
return currentStep !== 0;
@@ -41,7 +46,7 @@ export const ServerResource: Resource = {
},
{
title: 'Set Up Access',
- component: LoginTrait,
+ component: SetupAccess,
},
{
title: 'Test Connection',
diff --git a/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.story.test.tsx b/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.story.test.tsx
new file mode 100644
index 0000000000000..e84be4af5ac8d
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.story.test.tsx
@@ -0,0 +1,50 @@
+/**
+ * Copyright 2022 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 { render } from 'design/utils/testing';
+
+import * as stories from './CommandWithTimer.story';
+
+test('render default polling', async () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+});
+
+test('render default polling success', async () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+});
+
+test('render default polling error', async () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+});
+
+test('render custom polling', async () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+});
+
+test('render custom polling success', async () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+});
+
+test('render custom polling error', async () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+});
diff --git a/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.story.tsx b/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.story.tsx
new file mode 100644
index 0000000000000..c0ce890216241
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.story.tsx
@@ -0,0 +1,74 @@
+/**
+ * Copyright 2022 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 { CommandWithTimer } from './CommandWithTimer';
+
+export default {
+ title: 'Teleport/Discover/Shared/CommandWithTimer',
+};
+
+export const DefaultPolling = () => (
+
+);
+
+export const DefaultPollingSuccess = () => (
+
+);
+
+export const DefaultPollingError = () => (
+ error reason 1>, <>error reason 2>] },
+ }}
+ />
+);
+
+export const CustomPolling = () => (
+ Custom Header Component}
+ />
+);
+
+export const CustomPollingSuccess = () => (
+
+);
+
+export const CustomPollingError = () => (
+ error reason 1>, <>error reason 2>],
+ customErrContent: <>custom error content>,
+ },
+ }}
+ />
+);
+
+const props = {
+ command: 'some kind of command',
+ pollingTimeout: 100000,
+};
diff --git a/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.tsx b/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.tsx
new file mode 100644
index 0000000000000..843cea591bf2c
--- /dev/null
+++ b/web/packages/teleport/src/Discover/Shared/CommandWithTimer/CommandWithTimer.tsx
@@ -0,0 +1,123 @@
+// Copyright 2022 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 { Text, Box } from 'design';
+import * as Icons from 'design/Icon';
+
+import { TextSelectCopyMulti } from 'teleport/components/TextSelectCopy';
+import { Timeout } from 'teleport/Discover/Shared/Timeout';
+import { TextIcon } from 'teleport/Discover/Shared';
+
+export const CommandWithTimer = ({
+ command,
+ poll,
+ pollingTimeout,
+ header,
+}: Props) => {
+ return (
+
+ {header || Command}
+
+
+
+ {poll.state === 'polling' && (
+
+
+
+
+ )}
+ {poll.state === 'success' && (
+
+
+ {poll.customStateDesc ||
+ 'The Teleport Service successfully join this Teleport cluster'}
+
+ )}
+ {poll.state === 'error' && (
+
+
+
+ {poll.error.customErrContent ||
+ 'We could not detect the Teleport Service you were trying to add'}
+
+
+ Possible reasons
+
+