diff --git a/ui/web-v2/package.json b/ui/web-v2/package.json index 36837ee0fd..209029f119 100644 --- a/ui/web-v2/package.json +++ b/ui/web-v2/package.json @@ -54,6 +54,8 @@ "jstat": "1.9.4", "jwt-decode": "^4.0.0", "option-t": "23.0.2", + "prism-react-renderer": "^2.4.1", + "prismjs": "^1.29.0", "prop-types": "^15.6.0", "query-string": "7.0.1", "react": "17.0.2", diff --git a/ui/web-v2/src/assets/lang/en.json b/ui/web-v2/src/assets/lang/en.json index 947e44cf9d..e71159e3e4 100644 --- a/ui/web-v2/src/assets/lang/en.json +++ b/ui/web-v2/src/assets/lang/en.json @@ -206,6 +206,17 @@ "button.stop": "Stop", "button.submit": "Submit", "close": "Close", + "codeRefs.branch": "Branch", + "codeRefs.description": "References to this feature flag found in your codebase", + "codeRefs.enableCodeRefs": "Enable code references", + "codeRefs.enableCodeRefsDescription": "Quickly see references of feature flags used in your codebase using our CLI tool. The result will be reported to our backend and shown on this tab.", + "codeRefs.fileExtensions": "File Extensions", + "codeRefs.multipleReferenceFound": "{value} reference(s) found in {branchLink} branch", + "codeRefs.noRefsInCodebase": "There are no code references in your codebase yet.", + "codeRefs.noRegisteredRefs": "No registered code references", + "codeRefs.referenceFound": "{value} reference found in {branchLink} branch", + "codeRefs.repository": "Repository", + "codeRefs.viewInSource": "View in Source", "copy.copied": "Copied!", "copy.copyToClipboard": "Copy to clipboard", "created": "Created", @@ -353,6 +364,7 @@ "feature.status": "status", "feature.strategy.selectRolloutPercentage": "Select rollout percentage", "feature.tab.autoOps": "Auto Operations", + "feature.tab.codeRefs": "Code Refs", "feature.tab.evaluation": "Evaluation", "feature.tab.experiments": "Experiments", "feature.tab.history": "History", @@ -634,4 +646,4 @@ "urlCode": "URL code", "warning": "Warning", "yes": "Yes" -} +} \ No newline at end of file diff --git a/ui/web-v2/src/assets/lang/ja.json b/ui/web-v2/src/assets/lang/ja.json index ae68c1fe44..ddc3eb3fc1 100644 --- a/ui/web-v2/src/assets/lang/ja.json +++ b/ui/web-v2/src/assets/lang/ja.json @@ -206,6 +206,17 @@ "button.stop": "終了", "button.submit": "送信", "close": "閉じる", + "codeRefs.branch": "ブランチ", + "codeRefs.description": "コードベースで見つかったコードリファレンスです", + "codeRefs.enableCodeRefs": "コードリファレンスを有効にする", + "codeRefs.enableCodeRefsDescription": "BucketeerのCLI ツールを利用して、コードベースで利用されているフィーチャーフラグの参照をすばやく検索してくれます。結果はバックエンドに報告され、このタブに表示されます。", + "codeRefs.fileExtensions": "ファイル拡張", + "codeRefs.multipleReferenceFound": "{branchLink}ブランチに {value} 件の参照がありました", + "codeRefs.noRefsInCodebase": "コードベースにはまだコードリファレンスがありません。", + "codeRefs.noRegisteredRefs": "コードリファレンスはありません", + "codeRefs.referenceFound": "{branchLink}ブランチに {value} 件の参照がありました", + "codeRefs.repository": "レポジトリー", + "codeRefs.viewInSource": "ソースを表示", "copy.copied": "コピーしました", "copy.copyToClipboard": "クリップボードにコピー", "created": "作成", @@ -353,6 +364,7 @@ "feature.status": "ステータス", "feature.strategy.selectRolloutPercentage": "割合で選択", "feature.tab.autoOps": "オートオペレーション", + "feature.tab.codeRefs": "コードリファレンス", "feature.tab.evaluation": "エバリュエーション", "feature.tab.experiments": "エクスペリメント", "feature.tab.history": "履歴", diff --git a/ui/web-v2/src/assets/svg/bitbucket-icon.svg b/ui/web-v2/src/assets/svg/bitbucket-icon.svg new file mode 100644 index 0000000000..4e72756fcd --- /dev/null +++ b/ui/web-v2/src/assets/svg/bitbucket-icon.svg @@ -0,0 +1,15 @@ + + + + + + + + + + \ No newline at end of file diff --git a/ui/web-v2/src/assets/svg/github-icon.svg b/ui/web-v2/src/assets/svg/github-icon.svg index 40b4ccdf80..c453f3d18f 100644 --- a/ui/web-v2/src/assets/svg/github-icon.svg +++ b/ui/web-v2/src/assets/svg/github-icon.svg @@ -1,8 +1,9 @@ - + - + \ No newline at end of file diff --git a/ui/web-v2/src/assets/svg/gitlab-icon.svg b/ui/web-v2/src/assets/svg/gitlab-icon.svg new file mode 100644 index 0000000000..e3c2da500d --- /dev/null +++ b/ui/web-v2/src/assets/svg/gitlab-icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/ui/web-v2/src/constants/routing.ts b/ui/web-v2/src/constants/routing.ts index 0ec581ed06..176adbb29a 100644 --- a/ui/web-v2/src/constants/routing.ts +++ b/ui/web-v2/src/constants/routing.ts @@ -28,6 +28,7 @@ export const PAGE_PATH_FEATURE_EVALUATION = '/evaluation'; export const PAGE_PATH_FEATURE_AUTOOPS = '/autoops'; export const PAGE_PATH_FEATURE_TRIGGER = '/trigger'; export const PAGE_PATH_FEATURE_HISTORY = '/history'; +export const PAGE_PATH_CODE_REFS = '/coderefs'; export const PAGE_PATH_AUTH_CALLBACK = '/auth/callback'; export const PAGE_PATH_AUTH_SIGNIN = '/auth/signin'; diff --git a/ui/web-v2/src/grpc/codeRefs.ts b/ui/web-v2/src/grpc/codeRefs.ts new file mode 100644 index 0000000000..e37a038d33 --- /dev/null +++ b/ui/web-v2/src/grpc/codeRefs.ts @@ -0,0 +1,74 @@ +import { Nullable, isNotNull, isNull } from 'option-t/lib/Nullable/Nullable'; +import { urls } from '../config'; +import { + CodeReferenceServiceClient, + ServiceError +} from '../proto/coderef/service_pb_service'; + +import { extractErrorMessage } from './messages'; +import { + checkUnauthenticatedError, + getMetaDataForClient as getMetaData +} from './utils'; +import { UNAUTHENTICATED_ERROR } from '../middlewares/thunkErrorHandler'; +import { + ListCodeReferencesRequest, + ListCodeReferencesResponse +} from '../proto/coderef/service_pb'; + +export class CodeRefsServiceError extends Error { + request: Request; + + error: Nullable; + + constructor( + message: string, + request: Request, + error: Nullable + ) { + if (checkUnauthenticatedError(error.code)) { + super(UNAUTHENTICATED_ERROR); + } else { + super(message); + } + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CodeRefsServiceError); + } + this.name = 'CodeRefsServiceError'; + this.request = request; + this.error = error; + } +} + +const client = new CodeReferenceServiceClient(urls.GRPC); + +export interface ListCodeRefsResult { + request: ListCodeReferencesRequest; + response: ListCodeReferencesResponse; +} + +export function listCodeRefs( + request: ListCodeReferencesRequest +): Promise { + return new Promise( + (resolve: (result: ListCodeRefsResult) => void, reject): void => { + client.listCodeReferences( + request, + getMetaData(), + (error, response): void => { + if (isNotNull(error) || isNull(response)) { + reject( + new CodeRefsServiceError( + extractErrorMessage(error), + request, + error + ) + ); + } else { + resolve({ request, response }); + } + } + ); + } + ); +} diff --git a/ui/web-v2/src/lang/messages.ts b/ui/web-v2/src/lang/messages.ts index a96cb25946..676a74cd73 100644 --- a/ui/web-v2/src/lang/messages.ts +++ b/ui/web-v2/src/lang/messages.ts @@ -722,6 +722,53 @@ export const messages = { defaultMessage: 'Updated' }) }, + codeRefs: { + description: defineMessage({ + id: 'codeRefs.description', + defaultMessage: 'References to this feature flag found in your codebase' + }), + repository: defineMessage({ + id: 'codeRefs.repository', + defaultMessage: 'Repository' + }), + branch: defineMessage({ + id: 'codeRefs.branch', + defaultMessage: 'Branch' + }), + fileExtensions: defineMessage({ + id: 'codeRefs.fileExtensions', + defaultMessage: 'File Extensions' + }), + viewInSource: defineMessage({ + id: 'codeRefs.viewInSource', + defaultMessage: 'View in Source' + }), + referenceFound: defineMessage({ + id: 'codeRefs.referenceFound', + defaultMessage: '{value} reference found in {branchLink} branch' + }), + multipleReferenceFound: defineMessage({ + id: 'codeRefs.multipleReferenceFound', + defaultMessage: '{value} reference(s) found in {branchLink} branch' + }), + noRegisteredRefs: defineMessage({ + id: 'codeRefs.noRegisteredRefs', + defaultMessage: 'No registered code references' + }), + noRefsInCodebase: defineMessage({ + id: 'codeRefs.noRefsInCodebase', + defaultMessage: 'There are no code references in your codebase yet.' + }), + enableCodeRefs: defineMessage({ + id: 'codeRefs.enableCodeRefs', + defaultMessage: 'Enable code references' + }), + enableCodeRefsDescription: defineMessage({ + id: 'codeRefs.enableCodeRefsDescription', + defaultMessage: + 'Quickly see references of feature flags used in your codebase using our CLI tool. The result will be reported to our backend and shown on this tab.' + }) + }, maintainer: defineMessage({ id: 'maintainer', defaultMessage: 'Maintainer' @@ -2105,6 +2152,10 @@ export const messages = { variations: defineMessage({ id: 'feature.tab.variations', defaultMessage: 'Variations' + }), + codeRefs: defineMessage({ + id: 'feature.tab.codeRefs', + defaultMessage: 'Code Refs' }) }, search: { diff --git a/ui/web-v2/src/modules/codeRefs.ts b/ui/web-v2/src/modules/codeRefs.ts new file mode 100644 index 0000000000..b8a27d2512 --- /dev/null +++ b/ui/web-v2/src/modules/codeRefs.ts @@ -0,0 +1,78 @@ +import { + createSlice, + createEntityAdapter, + createAsyncThunk +} from '@reduxjs/toolkit'; + +import * as grpc from '../grpc/codeRefs'; +import { CodeReference } from '../proto/coderef/code_reference_pb'; +import { AppState } from '.'; +import { + ListCodeReferencesRequest, + ListCodeReferencesResponse +} from '../proto/coderef/service_pb'; + +const MODULE_NAME = 'codeRefs'; + +export const codeRefsAdapter = createEntityAdapter({ + selectId: (codeRef: CodeReference.AsObject) => codeRef.id +}); + +export const { selectAll, selectById } = codeRefsAdapter.getSelectors(); + +interface ListCodeReferenceParams { + environmentId: string; + featureId: string; + pageSize: number; + fileExtension: string; + repositoryBranch: string; + repositoryType: CodeReference.RepositoryTypeMap[keyof CodeReference.RepositoryTypeMap]; +} + +export const listCodeRefs = createAsyncThunk< + ListCodeReferencesResponse.AsObject, + ListCodeReferenceParams | undefined, + { state: AppState } +>(`${MODULE_NAME}/list`, async (params) => { + const request = new ListCodeReferencesRequest(); + + request.setEnvironmentId(params.environmentId); + request.setFeatureId(params.featureId); + request.setPageSize(params.pageSize); + params.repositoryBranch && + request.setRepositoryBranch(params.repositoryBranch); + params.repositoryType && request.setRepositoryType(params.repositoryType); + params.fileExtension && request.setFileExtension(params.fileExtension); + + const result = await grpc.listCodeRefs(request); + return result.response.toObject(); +}); + +const initialState = codeRefsAdapter.getInitialState<{ + loading: boolean; + totalCount: number; +}>({ + loading: false, + totalCount: 0 +}); + +export const codeRefsSlice = createSlice({ + name: MODULE_NAME, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(listCodeRefs.pending, (state) => { + state.loading = true; + }) + .addCase(listCodeRefs.fulfilled, (state, action) => { + codeRefsAdapter.removeAll(state); + codeRefsAdapter.upsertMany(state, action.payload.codeReferencesList); + state.totalCount = action.payload.totalCount; + state.loading = false; + }) + .addCase(listCodeRefs.rejected, (state) => { + state.loading = false; + }); + } +}); diff --git a/ui/web-v2/src/modules/index.ts b/ui/web-v2/src/modules/index.ts index f25c693651..05eb12b574 100644 --- a/ui/web-v2/src/modules/index.ts +++ b/ui/web-v2/src/modules/index.ts @@ -4,6 +4,7 @@ import { ThunkAction } from 'redux-thunk'; import { accountsSlice } from './accounts'; import { adminNotificationSlice } from './adminNotifications'; import { apiKeySlice } from './apiKeys'; +import { codeRefsSlice } from './codeRefs'; import { auditLogSlice } from './auditLogs'; import { authSlice } from './auth'; import { autoOpsRulesSlice } from './autoOpsRules'; @@ -30,6 +31,7 @@ export const reducers = combineReducers({ adminNotification: adminNotificationSlice.reducer, auditLog: auditLogSlice.reducer, apiKeys: apiKeySlice.reducer, + codeRefs: codeRefsSlice.reducer, auth: authSlice.reducer, autoOpsRules: autoOpsRulesSlice.reducer, progressiveRollout: progressiveRolloutSlice.reducer, diff --git a/ui/web-v2/src/pages/feature/coderefs.tsx b/ui/web-v2/src/pages/feature/coderefs.tsx new file mode 100644 index 0000000000..7b57eb905c --- /dev/null +++ b/ui/web-v2/src/pages/feature/coderefs.tsx @@ -0,0 +1,528 @@ +import React, { FC, memo, useEffect, useState } from 'react'; +import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import { useCurrentEnvironment } from '../../modules/me'; +import { AppDispatch } from '../../store'; +import { listAPIKeys } from '../../modules/apiKeys'; +import { getOrganizationId } from '../../storage/organizationId'; +import { APIKEY_LIST_PAGE_SIZE } from '../../constants/apiKey'; +import { + listCodeRefs, + selectAll as selectAllCodeRefs +} from '../../modules/codeRefs'; +import { APIKey } from '../../proto/account/api_key_pb'; +import { ListAPIKeysResponse } from '../../proto/account/service_pb'; +import { ListCodeReferencesRequest } from '../../proto/coderef/service_pb'; +import { AppState } from '../../modules'; +import { PAGE_PATH_APIKEYS, PAGE_PATH_ROOT } from '../../constants/routing'; +import { DetailSkeleton } from '../../components/DetailSkeleton'; +import { CodeReference } from '../../proto/coderef/code_reference_pb'; +import { classNames } from '../../utils/css'; +import { Option, Select } from '../../components/Select'; +import { components } from 'react-select'; +import GithubIcon from '../../assets/svg/github-icon.svg'; +import GitlabIcon from '../../assets/svg/gitlab-icon.svg'; +import BitbucketIcon from '../../assets/svg/bitbucket-icon.svg'; +import { ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/outline'; +import { useIntl } from 'react-intl'; +import { messages } from '../../lang/messages'; +import { ListSkeleton } from '../../components/ListSkeleton'; +import { Highlight, themes, Prism } from 'prism-react-renderer'; + +(typeof global !== 'undefined' ? global : window).Prism = Prism; +require('prismjs/components/prism-dart'); + +const repositoryTypeMap = { + [CodeReference.RepositoryType.GITHUB]: { + label: 'Github', + icon: + }, + [CodeReference.RepositoryType.GITLAB]: { + label: 'Gitlab', + icon: + }, + [CodeReference.RepositoryType.BITBUCKET]: { + label: 'Bitbucket', + icon: + } +}; + +interface FeatureCodeRefsPageProps { + featureId: string; +} + +export const FeatureCodeRefsPage: FC = memo( + ({ featureId }) => { + const dispatch = useDispatch(); + const currentEnvironment = useCurrentEnvironment(); + const history = useHistory(); + const { formatMessage: f } = useIntl(); + + const [isLoading, setIsLoading] = React.useState(true); + const [hasValidApiKey, setHasValidApiKey] = React.useState(false); + + const [selectedRepository, setSelectedRepository] = useState{props.name}: {children} + + ); + }; + + useEffect(() => { + const fetchApiKeysAndCodeRefs = async () => { + try { + const res = await dispatch( + listAPIKeys({ + organizationId: getOrganizationId(), + environmentIds: [currentEnvironment.id], + pageSize: APIKEY_LIST_PAGE_SIZE, + cursor: '0', + searchKeyword: '', + orderBy: ListCodeReferencesRequest.OrderBy.DEFAULT, + orderDirection: ListCodeReferencesRequest.OrderDirection.ASC + }) + ); + const { apiKeysList } = res.payload as ListAPIKeysResponse.AsObject; + + const validApiKey = apiKeysList.some( + (apiKey) => + apiKey.role === APIKey.Role.PUBLIC_API_ADMIN || + apiKey.role === APIKey.Role.PUBLIC_API_WRITE + ); + + if (validApiKey) { + setHasValidApiKey(true); + } + } catch (error) { + console.error('Error fetching API keys or code references:', error); + } finally { + setIsLoading(false); + } + }; + + fetchApiKeysAndCodeRefs(); + }, []); + + useEffect(() => { + const fetchFilteredCodeRefs = async () => { + try { + const repositoryType = selectedRepository + ? Number(selectedRepository.value) + : CodeReference.RepositoryType.REPOSITORY_TYPE_UNSPECIFIED; + const repositoryBranch = selectedBranch ? selectedBranch.value : null; + const fileExtension = selectedFileExtension + ? selectedFileExtension.value + : null; + + await fetchCodeRefs({ + repositoryType: + repositoryType as CodeReference.RepositoryTypeMap[keyof CodeReference.RepositoryTypeMap], + repositoryBranch, + fileExtension + }); + } catch (error) { + console.error('Error fetching filtered code references:', error); + } + }; + + if (hasValidApiKey) { + fetchFilteredCodeRefs(); + } + }, [ + selectedRepository, + selectedBranch, + selectedFileExtension, + hasValidApiKey + ]); + + useEffect(() => { + if (codeRefs.length > 0) { + if (branchOptions.length === 0) { + const uniqueBranches = [ + ...new Set(codeRefs.map((codeRef) => codeRef.repositoryBranch)) + ]; + const formattedBranches = uniqueBranches.map((branch) => ({ + label: branch.charAt(0).toUpperCase() + branch.slice(1), + value: branch + })); + setBranchOptions([ + { label: 'All', value: null }, + ...formattedBranches + ]); + } + + if (fileExtensionOptions.length === 0) { + const uniqueFileExtensions = [ + ...new Set( + codeRefs.map((codeRef) => codeRef.fileExtension).filter(Boolean) + ) + ]; + setFileExtensionOptions([ + { label: 'All', value: null }, + ...uniqueFileExtensions.map((fileExtension) => ({ + label: fileExtension, + value: fileExtension + })) + ]); + } + + if (repositoryOptions.length === 0) { + const uniqueRepositoryOptions = [ + ...new Set( + codeRefs.map((codeRef) => codeRef.repositoryType).filter(Boolean) + ) + ]; + setRepositoryOptions([ + { + label: 'All', + value: null + }, + ...uniqueRepositoryOptions.map((repositoryType) => ({ + label: repositoryTypeMap[repositoryType]?.label, + value: repositoryType.toString() + })) + ]); + } + } + }, [codeRefs]); + + const fetchCodeRefs = async ({ + fileExtension = null, + repositoryBranch = null, + repositoryType = null + }: { + fileExtension?: string; + repositoryBranch?: string; + repositoryType?: CodeReference.RepositoryTypeMap[keyof CodeReference.RepositoryTypeMap]; + } = {}) => { + return await dispatch( + listCodeRefs({ + environmentId: currentEnvironment.id, + featureId: featureId, + pageSize: 0, + fileExtension, + repositoryBranch, + repositoryType + }) + ); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (!hasValidApiKey) { + return ( +
+
+
+

+ {f(messages.codeRefs.enableCodeRefs)} +

+

+ {f(messages.codeRefs.enableCodeRefsDescription)} +

+
+ +
+
+ ); + } + + if ( + !selectedBranch && + !selectedFileExtension && + !selectedRepository && + codeRefs.length === 0 + ) { + return ( +
+
+

+ {f(messages.codeRefs.noRegisteredRefs)} +

+

{f(messages.codeRefs.noRefsInCodebase)}

+
+
+ ); + } + + return ( +
+
+
+

+ {f(messages.feature.tab.codeRefs)} +

+

+ {f(messages.codeRefs.description)} +

+
+
+ ( + + )} + /> +