diff --git a/backend/src/integrations/apps.ts b/backend/src/integrations/apps.ts index fa0020d616..f8ed772775 100644 --- a/backend/src/integrations/apps.ts +++ b/backend/src/integrations/apps.ts @@ -16,6 +16,7 @@ import { INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, INTEGRATION_SUPABASE, + INTEGRATION_CHECKLY, INTEGRATION_HEROKU_API_URL, INTEGRATION_GITLAB_API_URL, INTEGRATION_VERCEL_API_URL, @@ -26,6 +27,7 @@ import { INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, INTEGRATION_SUPABASE_API_URL, + INTEGRATION_CHECKLY_API_URL } from "../variables"; interface App { @@ -120,6 +122,11 @@ const getApps = async ({ accessToken, }); break; + case INTEGRATION_CHECKLY: + apps = await getAppsCheckly({ + accessToken, + }); + break; } return apps; @@ -601,4 +608,32 @@ const getAppsSupabase = async ({ accessToken }: { accessToken: string }) => { return apps; }; +/** + * Return list of projects for the Checkly integration + * @param {Object} obj + * @param {String} obj.accessToken - api key for the Checkly API + * @returns {Object[]} apps - Сheckly accounts + * @returns {String} apps.name - name of Checkly account + */ +const getAppsCheckly = async ({ accessToken }: { accessToken: string }) => { + const { data } = await standardRequest.get( + `${INTEGRATION_CHECKLY_API_URL}/v1/accounts`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Accept": "application/json", + }, + } + ); + + const apps = data.map((a: any) => { + return { + name: a.name, + appId: a.id, + }; + }); + + return apps; +}; + export { getApps }; diff --git a/backend/src/integrations/sync.ts b/backend/src/integrations/sync.ts index 751d52793e..0722a403e3 100644 --- a/backend/src/integrations/sync.ts +++ b/backend/src/integrations/sync.ts @@ -34,7 +34,9 @@ import { INTEGRATION_FLYIO_API_URL, INTEGRATION_CIRCLECI_API_URL, INTEGRATION_TRAVISCI_API_URL, - INTEGRATION_SUPABASE_API_URL + INTEGRATION_SUPABASE_API_URL, + INTEGRATION_CHECKLY, + INTEGRATION_CHECKLY_API_URL } from "../variables"; import { standardRequest} from '../config/request'; @@ -161,8 +163,47 @@ const syncSecrets = async ({ integration, secrets, accessToken - }); - break; + }); + break; + case INTEGRATION_FLYIO: + await syncSecretsFlyio({ + integration, + secrets, + accessToken, + }); + break; + case INTEGRATION_CIRCLECI: + await syncSecretsCircleCI({ + integration, + secrets, + accessToken, + }); + break; + case INTEGRATION_TRAVISCI: + await syncSecretsTravisCI({ + integration, + secrets, + accessToken, + }); + break; + case INTEGRATION_SUPABASE: + await syncSecretsSupabase({ + integration, + secrets, + accessToken + }); + break; + case INTEGRATION_CHECKLY: + await syncSecretsCheckly({ + integration, + secrets, + accessToken, + }); + break; + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); } }; @@ -1629,4 +1670,103 @@ const syncSecretsSupabase = async ({ }; +/** + * Sync/push [secrets] to Checkly app + * @param {Object} obj + * @param {IIntegration} obj.integration - integration details + * @param {Object} obj.secrets - secrets to push to integration (object where keys are secret keys and values are secret values) + * @param {String} obj.accessToken - access token for Checkly integration + */ +const syncSecretsCheckly = async ({ + integration, + secrets, + accessToken, +}: { + integration: IIntegration; + secrets: any; + accessToken: string; +}) => { + try { + // get secrets from travis-ci + const getSecretsRes = ( + await standardRequest.get( + `${INTEGRATION_CHECKLY_API_URL}/v1/variables`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept-Encoding": "application/json", + "X-Checkly-Account": integration.appId + }, + } + ) + ) + .data + .reduce((obj: any, secret: any) => ({ + ...obj, + [secret.key]: secret.value + }), {}); + + // add secrets + for await (const key of Object.keys(secrets)) { + if (!(key in getSecretsRes)) { + // case: secret does not exist in checkly + // -> add secret + await standardRequest.post( + `${INTEGRATION_CHECKLY_API_URL}/v1/variables`, + { + key, + value: secrets[key] ? secrets[key] : 'EMPTY' + }, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "Content-Type": "application/json", + "X-Checkly-Account": integration.appId + }, + } + ); + } else { + // case: secret exists in checkly + // -> update/set secret + await standardRequest.put( + `${INTEGRATION_CHECKLY_API_URL}/v1/variables/${key}`, + { + value: secrets[key] ? secrets[key] : 'EMPTY' + }, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Accept": "application/json", + "X-Checkly-Account": integration.appId + }, + } + ); + } + } + + for await (const key of Object.keys(getSecretsRes)) { + if (!(key in secrets)){ + // delete secret + await standardRequest.delete( + `${INTEGRATION_CHECKLY_API_URL}/v1/variables/${key}`, + { + headers: { + "Authorization": `Bearer ${accessToken}`, + "Accept": "application/json", + "X-Checkly-Account": integration.appId + }, + } + ); + } + } + } catch (err) { + Sentry.setUser(null); + Sentry.captureException(err); + throw new Error("Failed to sync secrets to Checkly"); + } +}; + + export { syncSecrets }; diff --git a/backend/src/models/integration.ts b/backend/src/models/integration.ts index 555f481f8f..504d4a2be5 100644 --- a/backend/src/models/integration.ts +++ b/backend/src/models/integration.ts @@ -13,7 +13,8 @@ import { INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, - INTEGRATION_SUPABASE + INTEGRATION_SUPABASE, + INTEGRATION_CHECKLY } from "../variables"; export interface IIntegration { @@ -45,7 +46,8 @@ export interface IIntegration { | 'flyio' | 'circleci' | 'travisci' - | 'supabase'; + | 'supabase' + | 'checkly'; integrationAuth: Types.ObjectId; } @@ -130,7 +132,8 @@ const integrationSchema = new Schema( INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, - INTEGRATION_SUPABASE + INTEGRATION_SUPABASE, + INTEGRATION_CHECKLY ], required: true, }, diff --git a/backend/src/models/integrationAuth.ts b/backend/src/models/integrationAuth.ts index affe811df9..4f2209f2d0 100644 --- a/backend/src/models/integrationAuth.ts +++ b/backend/src/models/integrationAuth.ts @@ -22,7 +22,7 @@ import { export interface IIntegrationAuth extends Document { _id: Types.ObjectId; workspace: Types.ObjectId; - integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'supabase' | 'aws-parameter-store' | 'aws-secret-manager'; + integration: 'heroku' | 'vercel' | 'netlify' | 'github' | 'gitlab' | 'render' | 'railway' | 'flyio' | 'azure-key-vault' | 'circleci' | 'travisci' | 'supabase' | 'aws-parameter-store' | 'aws-secret-manager' | 'checkly'; teamId: string; accountId: string; refreshCiphertext?: string; diff --git a/backend/src/variables/integration.ts b/backend/src/variables/integration.ts index 6efd968179..7fa8f6a23a 100644 --- a/backend/src/variables/integration.ts +++ b/backend/src/variables/integration.ts @@ -22,6 +22,7 @@ export const INTEGRATION_FLYIO = "flyio"; export const INTEGRATION_CIRCLECI = "circleci"; export const INTEGRATION_TRAVISCI = "travisci"; export const INTEGRATION_SUPABASE = 'supabase'; +export const INTEGRATION_CHECKLY = 'checkly'; export const INTEGRATION_SET = new Set([ INTEGRATION_AZURE_KEY_VAULT, INTEGRATION_HEROKU, @@ -33,7 +34,8 @@ export const INTEGRATION_SET = new Set([ INTEGRATION_FLYIO, INTEGRATION_CIRCLECI, INTEGRATION_TRAVISCI, - INTEGRATION_SUPABASE + INTEGRATION_SUPABASE, + INTEGRATION_CHECKLY ]); // integration types @@ -60,6 +62,7 @@ export const INTEGRATION_FLYIO_API_URL = "https://api.fly.io/graphql"; export const INTEGRATION_CIRCLECI_API_URL = "https://circleci.com/api"; export const INTEGRATION_TRAVISCI_API_URL = "https://api.travis-ci.com"; export const INTEGRATION_SUPABASE_API_URL = 'https://api.supabase.com'; +export const INTEGRATION_CHECKLY_API_URL = 'https://api.checklyhq.com'; export const getIntegrationOptions = async () => { const INTEGRATION_OPTIONS = [ @@ -190,6 +193,15 @@ export const getIntegrationOptions = async () => { clientId: '', docsLink: '' }, + { + name: 'Checkly', + slug: 'checkly', + image: 'Checkly.png', + isAvailable: true, + type: 'pat', + clientId: '', + docsLink: '' + }, { name: 'Google Cloud Platform', slug: 'gcp', diff --git a/frontend/public/data/frequentConstants.ts b/frontend/public/data/frequentConstants.ts index 40c88f65b5..5efc33e273 100644 --- a/frontend/public/data/frequentConstants.ts +++ b/frontend/public/data/frequentConstants.ts @@ -16,7 +16,8 @@ const integrationSlugNameMapping: Mapping = { 'flyio': 'Fly.io', 'circleci': 'CircleCI', 'travisci': 'TravisCI', - 'supabase': 'Supabase' + 'supabase': 'Supabase', + 'checkly': 'Checkly' } const envMapping: Mapping = { diff --git a/frontend/public/images/integrations/Checkly.png b/frontend/public/images/integrations/Checkly.png new file mode 100644 index 0000000000..563513d4b4 Binary files /dev/null and b/frontend/public/images/integrations/Checkly.png differ diff --git a/frontend/src/components/basic/buttons/Button.tsx b/frontend/src/components/basic/buttons/Button.tsx index 890650d965..461d160bbe 100644 --- a/frontend/src/components/basic/buttons/Button.tsx +++ b/frontend/src/components/basic/buttons/Button.tsx @@ -57,7 +57,7 @@ const Button = ({ color === 'mineshaft' && !activityStatus && 'bg-mineshaft', (color === 'primary' || !color) && activityStatus && 'bg-primary border border-primary-400 opacity-80 hover:opacity-100', (color === 'primary' || !color) && !activityStatus && 'bg-primary', - color === 'red' && 'bg-red', + color === 'red' && 'bg-red-800 border border-red', // Changing the opacity when active vs when not activityStatus ? 'opacity-100 cursor-pointer' : 'opacity-40', diff --git a/frontend/src/components/integrations/CloudIntegration.tsx b/frontend/src/components/integrations/CloudIntegration.tsx index 9536957ce9..cbd5853227 100644 --- a/frontend/src/components/integrations/CloudIntegration.tsx +++ b/frontend/src/components/integrations/CloudIntegration.tsx @@ -1,5 +1,5 @@ import Image from 'next/image'; -import { faCheck, faX } from '@fortawesome/free-solid-svg-icons'; +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import deleteIntegrationAuth from '../../pages/api/integrations/DeleteIntegrationAuth'; @@ -44,9 +44,9 @@ const CloudIntegration = ({ tabIndex={0} className={`relative ${ cloudIntegrationOption.isAvailable - ? 'cursor-pointer duration-200 hover:bg-white/10' + ? 'cursor-pointer duration-200 hover:bg-mineshaft-700' : 'opacity-50' - } flex h-32 flex-row items-center rounded-md bg-white/5 p-4`} + } flex h-32 flex-row items-center rounded-md bg-mineshaft-800 border border-mineshaft-600 p-4`} onClick={() => { if (!cloudIntegrationOption.isAvailable) return; setSelectedIntegrationOption(cloudIntegrationOption); @@ -95,12 +95,12 @@ const CloudIntegration = ({ integrationAuth: deletedIntegrationAuth }); }} - className="flex w-max cursor-pointer flex-row items-center rounded-b-md bg-red py-0.5 px-2 text-xs opacity-0 duration-200 group-hover:opacity-100" + className="flex w-max cursor-pointer flex-row items-center rounded-bl-md bg-red py-0.5 px-2 text-xs opacity-30 duration-200 group-hover:opacity-100" > - + Revoke -
+
Authorized
diff --git a/frontend/src/components/integrations/FrameworkIntegration.tsx b/frontend/src/components/integrations/FrameworkIntegration.tsx index 8a494059a1..56441c3d28 100644 --- a/frontend/src/components/integrations/FrameworkIntegration.tsx +++ b/frontend/src/components/integrations/FrameworkIntegration.tsx @@ -12,10 +12,10 @@ const FrameworkIntegration = ({ framework }: { framework: Framework }) => ( href={framework.docsLink} rel="noopener noreferrer" target="_blank" - className="relative flex flex-row justify-center bg-bunker-500 hover:bg-gradient-to-tr duration-200 h-32 rounded-md p-0.5 items-center cursor-pointer" + className="relative flex flex-row justify-center duration-200 h-32 rounded-md p-0.5 items-center cursor-pointer" >
1 ? 'text-sm px-1' : 'text-xl px-2' } text-center w-full max-w-xs`} > diff --git a/frontend/src/components/integrations/Integration.tsx b/frontend/src/components/integrations/Integration.tsx index 1597df84b4..d081e1a9a9 100644 --- a/frontend/src/components/integrations/Integration.tsx +++ b/frontend/src/components/integrations/Integration.tsx @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { useEffect, useState } from 'react'; import { useRouter } from 'next/router'; -import { faArrowRight, faRotate, faX } from '@fortawesome/free-solid-svg-icons'; +import { faArrowRight, faCheck, faXmark } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; // TODO: This needs to be moved from public folder import { contextNetlifyMapping, integrationSlugNameMapping, reverseContextNetlifyMapping } from 'public/data/frequentConstants'; @@ -212,10 +212,10 @@ const IntegrationTile = ({ return
; }; - if (!integrationApp) return
; + if (!integrationApp && integration.integration !== "checkly") return
; return ( -
+

ENVIRONMENT

@@ -238,27 +238,27 @@ const IntegrationTile = ({

INTEGRATION

-
+
{/* {integration.integration.charAt(0).toUpperCase() + integration.integration.slice(1)} */} {integrationSlugNameMapping[integration.integration]}
APP
- app.name) : null} isSelected={integrationApp} onChange={(app) => { setIntegrationApp(app); }} - /> + />
:
-
}
{renderIntegrationSpecificParams(integration)}
-
+
{integration.isActive ? ( -
- +
+
In Sync
) : ( @@ -269,7 +269,7 @@ const IntegrationTile = ({ size="md" /> )} -
+
diff --git a/frontend/src/components/integrations/IntegrationSection.tsx b/frontend/src/components/integrations/IntegrationSection.tsx index 5d3c88d2bd..9b8799b6a5 100644 --- a/frontend/src/components/integrations/IntegrationSection.tsx +++ b/frontend/src/components/integrations/IntegrationSection.tsx @@ -37,7 +37,7 @@ const ProjectIntegrationSection = ({

Current Integrations

-

Manage integrations with third-party services.

+

Manage integrations with third-party services.

{integrations.map((integration: Integration) => { return ( diff --git a/frontend/src/components/v2/Select/Select.tsx b/frontend/src/components/v2/Select/Select.tsx index 17e6060503..9d2ab02e35 100644 --- a/frontend/src/components/v2/Select/Select.tsx +++ b/frontend/src/components/v2/Select/Select.tsx @@ -40,7 +40,7 @@ export const Select = forwardRef( ref={ref} className={twMerge( `inline-flex items-center justify-between rounded-md - bg-bunker-800 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-gray-500`, + bg-mineshaft-900 px-3 py-2 font-inter text-sm font-normal text-bunker-200 outline-none data-[placeholder]:text-gray-500`, className )} > @@ -56,7 +56,7 @@ export const Select = forwardRef( +
{t('common.head-title', { title: t('integrations.title') })} @@ -392,7 +398,7 @@ export default function Integrations() { -
+
{ + try { + setAccessTokenErrorText(''); + if (accessToken.length === 0) { + setAccessTokenErrorText('Access token cannot be blank'); + return; + } + + setIsLoading(true); + + const integrationAuth = await saveIntegrationAccessToken({ + workspaceId: localStorage.getItem('projectData.id'), + integration: 'checkly', + accessId: null, + accessToken + }); + + setIsLoading(false); + + router.push(`/integrations/checkly/create?integrationAuthId=${integrationAuth._id}`); + } catch (err) { + console.error(err); + } + }; + + return ( +
+ + Checkly Integration + + setAccessToken(e.target.value)} + /> + + + +
+ ); +} + +ChecklyCreateIntegrationPage.requireAuth = true; diff --git a/frontend/src/pages/integrations/checkly/create.tsx b/frontend/src/pages/integrations/checkly/create.tsx new file mode 100644 index 0000000000..c76bb87b03 --- /dev/null +++ b/frontend/src/pages/integrations/checkly/create.tsx @@ -0,0 +1,141 @@ +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/router'; +import queryString from 'query-string'; + +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from '../../../components/v2'; +import { + useGetIntegrationAuthApps, + useGetIntegrationAuthById +} from '../../../hooks/api/integrationAuth'; +import { useGetWorkspaceById } from '../../../hooks/api/workspace'; +import createIntegration from '../../api/integrations/createIntegration'; + +export default function ChecklyCreateIntegrationPage() { + const router = useRouter(); + + const { integrationAuthId } = queryString.parse(router.asPath.split('?')[1]); + + const { data: workspace } = useGetWorkspaceById(localStorage.getItem('projectData.id') ?? ''); + const { data: integrationAuth } = useGetIntegrationAuthById((integrationAuthId as string) ?? ''); + const { data: integrationAuthApps } = useGetIntegrationAuthApps({ + integrationAuthId: (integrationAuthId as string) ?? '' + }); + + const [selectedSourceEnvironment, setSelectedSourceEnvironment] = useState(''); + const [targetApp, setTargetApp] = useState(''); + const [targetAppId, setTargetAppId] = useState(''); + + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (workspace) { + setSelectedSourceEnvironment(workspace.environments[0].slug); + } + }, [workspace]); + + useEffect(() => { + // TODO: handle case where apps can be empty + if (integrationAuthApps) { + if (integrationAuthApps.length > 0) { + setTargetApp(integrationAuthApps[0].name); + setTargetAppId(String(integrationAuthApps[0].appId)); + } else { + setTargetApp('none'); + } + } + }, [integrationAuthApps]); + + const handleButtonClick = async () => { + try { + if (!integrationAuth?._id) return; + + setIsLoading(true); + + await createIntegration({ + integrationAuthId: integrationAuth?._id, + isActive: true, + app: targetApp, + appId: targetAppId, + sourceEnvironment: selectedSourceEnvironment, + targetEnvironment: null, + targetEnvironmentId: null, + targetService: null, + targetServiceId: null, + owner: null, + path: null, + region: null + }); + + setIsLoading(false); + + router.push(`/integrations/${localStorage.getItem('projectData.id')}`); + } catch (err) { + console.error(err); + } + }; + + return integrationAuth && + workspace && + selectedSourceEnvironment && + integrationAuthApps && + targetApp ? ( +
+ + Checkly Integration + + + + + + + + +
+ ) : ( +
+ ); +} + +ChecklyCreateIntegrationPage.requireAuth = true;