├── changes └── .gitkeep ├── .husky ├── .gitignore └── pre-commit ├── charts ├── substra-frontend │ ├── changes │ │ └── .gitkeep │ ├── templates │ │ ├── serviceaccount.yaml │ │ ├── service.yaml │ │ ├── networkpolicy.yaml │ │ ├── ingress.yaml │ │ ├── NOTES.txt │ │ └── _helpers.tpl │ ├── Chart.yaml │ ├── .helmignore │ └── values.yaml ├── substra-frontend-tests │ ├── Chart.yaml │ ├── templates │ │ ├── serviceaccount.yaml │ │ └── _helpers.tpl │ └── values.yaml └── towncrier.toml ├── jest.setup.ts ├── .github ├── CODEOWNERS ├── workflows │ ├── docker-build.yaml │ ├── towncrier-changelog.yml │ ├── validate-pr.yml │ ├── helm.yaml │ └── release.yaml ├── pull_request_template.md ├── ISSUE_TEMPLATE │ └── config.yml └── dependabot.yml ├── public ├── fonts │ ├── Inter-Bold.ttf │ ├── Inter-Medium.ttf │ ├── Inter-Regular.ttf │ ├── Inter-SemiBold.ttf │ ├── Gattica-Bold100.otf │ ├── Gattica-Light100.otf │ ├── Gattica-Medium100.otf │ └── Gattica-Regular100.otf └── favicon.svg ├── src ├── assets │ ├── login-background.png │ ├── svg │ │ └── angle-icon.svg │ └── Fonts.tsx ├── types │ ├── OrganizationsTypes.ts │ ├── DataSampleTypes.ts │ ├── MeTypes.ts │ ├── UsersTypes.ts │ ├── ModelsTypes.ts │ ├── DocsTypes.ts │ ├── PerformancesTypes.ts │ ├── CPWorkflowTypes.ts │ ├── ProfilingTypes.ts │ ├── BearerTokenTypes.ts │ ├── MetadataTypes.ts │ ├── FunctionsTypes.ts │ ├── CommonTypes.ts │ ├── NewsFeedTypes.ts │ ├── SeriesTypes.ts │ └── DatasetTypes.ts ├── global.d.ts ├── api │ ├── MetadataApi.ts │ ├── CommonApi.ts │ ├── OrganizationsApi.ts │ ├── DocsApi.ts │ ├── CPWorkflowApi.ts │ ├── ProfilingApi.ts │ ├── NewsFeedApi.ts │ ├── BearerTokenApi.ts │ ├── FunctionsApi.ts │ ├── MeApi.ts │ ├── DatasetsApi.ts │ └── TasksApi.ts ├── hooks │ ├── useEffectOnce.ts │ ├── useHasPermission.ts │ ├── useCanDownloadModel.tsx │ ├── useKeyPress.ts │ ├── useDocumentTitleEffect.ts │ ├── useWithAbortController.ts │ ├── useSelection.ts │ ├── useFavoriteComputePlans.tsx │ ├── useBuildPerfChartDataset.tsx │ └── useLocationWithParams.ts ├── components │ ├── RefreshButton.tsx │ ├── CodeHighlighter.tsx │ ├── table │ │ ├── TableTitle.tsx │ │ ├── AssetsTable.tsx │ │ └── TablePagination.tsx │ ├── IconTag.tsx │ ├── MarkdownSection.tsx │ ├── ComputePlanTaskStatuses.tsx │ ├── MetadataDrawerSection.tsx │ ├── layout │ │ └── applayout │ │ │ └── AppLayout.tsx │ ├── Duration.tsx │ ├── DownloadIconButton.tsx │ ├── Status.tsx │ ├── PerfIconTag.tsx │ ├── MetadataModalTr.tsx │ ├── Timing.tsx │ ├── EmptyState.tsx │ ├── Breadcrumbs.tsx │ ├── PermissionTag.tsx │ ├── DrawerHeader.tsx │ └── ComputePlanProgressBar.tsx ├── routes │ ├── home │ │ └── Home.tsx │ ├── dataset │ │ └── components │ │ │ ├── MoreMenu.tsx │ │ │ ├── BreadCrumbs.tsx │ │ │ ├── DetailsSidebar.tsx │ │ │ └── DataSamplesDrawerSection.tsx │ ├── computePlanDetails │ │ ├── ComputePlanRoot.tsx │ │ ├── workflow │ │ │ ├── components │ │ │ │ └── UnavailableWorkflow.tsx │ │ │ └── useWorkflowStore.ts │ │ ├── components │ │ │ ├── CancelComputePlanMenuItem.tsx │ │ │ └── TasksBreadCrumbs.tsx │ │ └── ComputePlanUtils.ts │ ├── tokens │ │ ├── BearerTokenUtils.ts │ │ └── components │ │ │ └── NewTokenAlert.tsx │ ├── users │ │ ├── components │ │ │ ├── PasswordValidationMessage.tsx │ │ │ ├── UserAwaitingApprovalPage.tsx │ │ │ ├── RoleInput.tsx │ │ │ └── UsernameInput.tsx │ │ └── UsersUtils.ts │ ├── functions │ │ ├── FunctionsUtils.ts │ │ ├── components │ │ │ ├── DescriptionDrawerSection.tsx │ │ │ └── FunctionDurationBar.tsx │ │ └── useFunctionsStore.tsx │ ├── computePlans │ │ ├── components │ │ │ ├── CheckboxTd.tsx │ │ │ ├── FavoriteBox.tsx │ │ │ └── StatusCell.tsx │ │ └── useComputePlansStore.tsx │ ├── tasks │ │ ├── components │ │ │ ├── TaskIOPermissions.tsx │ │ │ └── TaskDurationBar.tsx │ │ └── useTasksStore.tsx │ ├── notfound │ │ └── NotFound.tsx │ ├── datasets │ │ └── useDatasetsStores.ts │ └── compare │ │ ├── useCompareStore.tsx │ │ └── components │ │ └── CompareBreadcrumbs.tsx ├── features │ ├── newsFeed │ │ ├── useLastNewsSeen.ts │ │ └── NewsFeedUtils.ts │ ├── customColumns │ │ ├── CustomColumnsUtils.ts │ │ ├── CustomColumnsTypes.ts │ │ └── useCustomColumns.ts │ ├── updateAsset │ │ ├── UpdateNameMenuItem.tsx │ │ └── UpdateNameButton.tsx │ ├── cookies │ │ ├── useCookieSettings.ts │ │ └── useCookie.ts │ ├── copy │ │ ├── CopyButton.tsx │ │ └── CopyIconButton.tsx │ ├── metadata │ │ ├── MetadataUtils.ts │ │ └── useMetadataStore.tsx │ ├── tableFilters │ │ ├── index.ts │ │ ├── TaskStatusTableFilter.tsx │ │ ├── ComputePlanStatusTableFilter.tsx │ │ ├── ComputePlanFavoritesTableFilter.tsx │ │ └── TableFilterCheckboxes.tsx │ ├── organizations │ │ ├── OrganizationsUtils.ts │ │ └── useOrganizationsStore.tsx │ ├── docs │ │ └── useReleasesInfoStore.ts │ └── perfBrowser │ │ ├── PerfLoadingState.tsx │ │ ├── PerfChartTooltip.tsx │ │ ├── PerfCard.tsx │ │ ├── PerfChartTooltipItem.tsx │ │ ├── PerfList.tsx │ │ ├── PerfSidebarSettings.tsx │ │ ├── PerfEmptyState.tsx │ │ ├── usePerfBrowserColors.tsx │ │ ├── PerfSidebarSettingsUnits.tsx │ │ └── PerfDetails.tsx ├── main.tsx └── libs │ └── utils.spec.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── .gitignore ├── .prettierignore ├── docker ├── substra-frontend-tests │ └── Dockerfile └── substra-frontend │ └── Dockerfile ├── skaffold-values ├── org-1.yaml └── org-2.yaml ├── automated-e2e-tests ├── readme.md └── skaffold.yaml ├── .dockerignore ├── knip.config.ts ├── .prettierrc.json ├── towncrier.toml ├── e2e-tests ├── package.json ├── cypress │ ├── e2e │ │ ├── login.cy.js │ │ ├── functions.cy.js │ │ ├── datasets.cy.js │ │ ├── tasks.cy.js │ │ ├── menu.cy.js │ │ ├── users.cy.js │ │ └── computePlans.cy.js │ ├── plugins │ │ └── index.js │ └── support │ │ └── e2e.js └── cypress.config.ts ├── index.html ├── jest.config.ts ├── .eslintrc.json ├── nginx └── nginx.conf ├── CONTRIBUTORS.md ├── tsconfig.json ├── ci └── readme.md ├── vite.config.ts └── skaffold.yaml /changes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /charts/substra-frontend/changes/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @hamdyd @jmorel @Substra/code-owners 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run eslint 2 | npm run knip 3 | npm run prettier 4 | -------------------------------------------------------------------------------- /public/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /public/fonts/Inter-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Inter-Medium.ttf -------------------------------------------------------------------------------- /public/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /public/fonts/Inter-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Inter-SemiBold.ttf -------------------------------------------------------------------------------- /src/assets/login-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/src/assets/login-background.png -------------------------------------------------------------------------------- /src/types/OrganizationsTypes.ts: -------------------------------------------------------------------------------- 1 | export type OrganizationT = { 2 | id: string; 3 | is_current: boolean; 4 | }; 5 | -------------------------------------------------------------------------------- /public/fonts/Gattica-Bold100.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Gattica-Bold100.otf -------------------------------------------------------------------------------- /public/fonts/Gattica-Light100.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Gattica-Light100.otf -------------------------------------------------------------------------------- /public/fonts/Gattica-Medium100.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Gattica-Medium100.otf -------------------------------------------------------------------------------- /public/fonts/Gattica-Regular100.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Substra/substra-frontend/HEAD/public/fonts/Gattica-Regular100.otf -------------------------------------------------------------------------------- /src/assets/svg/angle-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Substra repositories' code of conduct is available in the Substra documentation [here](https://docs.substra.org/en/stable/contributing/code-of-conduct.html). 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Substra repositories' contributing guide is available in the Substra documentation [here](https://docs.substra.org/en/stable/contributing/contributing-guide.html). 2 | -------------------------------------------------------------------------------- /src/types/DataSampleTypes.ts: -------------------------------------------------------------------------------- 1 | export type DataSampleT = { 2 | creation_date: string; 3 | data_manager_keys: string[]; 4 | key: string; 5 | owner: string; 6 | }; 7 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | declare const MICROSOFT_CLARITY_ID: string; 2 | declare const DEFAULT_PAGE_SIZE: number; 3 | declare const __APP_VERSION__: string; 4 | declare const API_URL: string; 5 | -------------------------------------------------------------------------------- /charts/substra-frontend-tests/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: substra-frontend-tests 3 | description: Tests for the Substra Frontend 4 | 5 | type: application 6 | 7 | version: 0.0.1 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | 7 | *.pyc 8 | .idea 9 | .vscode 10 | 11 | e2e-tests/cypress/videos/ 12 | e2e-tests/cypress/screenshots/ 13 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | build 3 | coverage 4 | node_modules 5 | .DS_Store 6 | dist 7 | dist-ssr 8 | *.local 9 | package-lock.json 10 | charts/ 11 | .vscode 12 | .github/ 13 | CHANGELOG.md 14 | -------------------------------------------------------------------------------- /docker/substra-frontend-tests/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM cypress/included:13.12.0 2 | 3 | RUN yarn add typescript@5.5.2 4 | 5 | WORKDIR /e2e 6 | 7 | COPY /e2e-tests/cypress /e2e/cypress 8 | COPY /e2e-tests/cypress.config.ts /e2e/cypress.config.ts 9 | #ENV DEBUG="cypress:*" -------------------------------------------------------------------------------- /src/api/MetadataApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise } from 'axios'; 2 | 3 | import API from '@/api/request'; 4 | import { API_PATHS } from '@/paths'; 5 | 6 | export const listMetadata = (): AxiosPromise => 7 | API.authenticatedGet(API_PATHS.METADATA); 8 | -------------------------------------------------------------------------------- /skaffold-values/org-1.yaml: -------------------------------------------------------------------------------- 1 | api: 2 | url: http://substra-backend.org-1.com 3 | ingress: 4 | enabled: true 5 | hosts: 6 | - host: substra-frontend.org-1.com 7 | paths: ['/'] 8 | annotations: 9 | kubernetes.io/ingress.class: nginx 10 | -------------------------------------------------------------------------------- /skaffold-values/org-2.yaml: -------------------------------------------------------------------------------- 1 | api: 2 | url: http://substra-backend.org-2.com 3 | ingress: 4 | enabled: true 5 | hosts: 6 | - host: substra-frontend.org-2.com 7 | paths: ['/'] 8 | annotations: 9 | kubernetes.io/ingress.class: nginx 10 | -------------------------------------------------------------------------------- /src/hooks/useEffectOnce.ts: -------------------------------------------------------------------------------- 1 | import { EffectCallback, useEffect } from 'react'; 2 | 3 | const useEffectOnce = (effect: EffectCallback) => { 4 | // eslint-disable-next-line react-hooks/exhaustive-deps 5 | useEffect(effect, []); 6 | }; 7 | 8 | export default useEffectOnce; 9 | -------------------------------------------------------------------------------- /src/api/CommonApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import API from '@/api/request'; 4 | 5 | export const retrieveDescription = ( 6 | url: string, 7 | config: AxiosRequestConfig 8 | ): AxiosPromise => API.authenticatedGet(url, config); 9 | -------------------------------------------------------------------------------- /automated-e2e-tests/readme.md: -------------------------------------------------------------------------------- 1 | ## Automated E2E tests 2 | 3 | Automated E2E tests are hosted on [substra-tests](https://github.com/Substra/substra-tests) 4 | 5 | The script there builds a custom cypress image defined here, and deploys it according 6 | to the Helm chart in `charts/substra-frontend-tests`. 7 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Git 2 | .git 3 | .gitignore 4 | .gitattributes 5 | .github 6 | 7 | # Build step outputs 8 | dist/ 9 | 10 | # Docker configuration 11 | .dockerignore 12 | docker/ 13 | Dockerfile 14 | skaffold.yaml 15 | 16 | # Helm configuration 17 | charts/ 18 | skaffold-values/ 19 | 20 | # NPM dependencies 21 | node_modules/ -------------------------------------------------------------------------------- /src/api/OrganizationsApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise } from 'axios'; 2 | 3 | import API from '@/api/request'; 4 | import { API_PATHS } from '@/paths'; 5 | import { OrganizationT } from '@/types/OrganizationsTypes'; 6 | 7 | export const listOrganizations = (): AxiosPromise => 8 | API.authenticatedGet(API_PATHS.ORGANIZATIONS); 9 | -------------------------------------------------------------------------------- /src/api/DocsApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise } from 'axios'; 2 | 3 | import { DOCS_API } from '@/api/request'; 4 | import { DOCS_API_PATHS } from '@/paths'; 5 | import { ReleasesInfoT } from '@/types/DocsTypes'; 6 | 7 | export const retrieveSubstraReleases = (): AxiosPromise => { 8 | return DOCS_API.get(DOCS_API_PATHS.RELEASES); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from '@chakra-ui/react'; 2 | 3 | const RefreshButton = (props: ButtonProps): JSX.Element => { 4 | return ( 5 | 8 | ); 9 | }; 10 | 11 | export default RefreshButton; 12 | -------------------------------------------------------------------------------- /charts/towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | directory = "changes" 3 | filename = "CHANGELOG.md" 4 | start_string = "\n" 5 | underlines = ["", "", ""] 6 | title_format = "## [{version}] - {project_date}" 7 | [tool.towncrier.fragment.added] 8 | [tool.towncrier.fragment.removed] 9 | [tool.towncrier.fragment.changed] 10 | [tool.towncrier.fragment.fixed] 11 | -------------------------------------------------------------------------------- /knip.config.ts: -------------------------------------------------------------------------------- 1 | import { type KnipConfig } from 'knip'; 2 | 3 | const config: KnipConfig = { 4 | ignore: ['e2e-tests/**'], 5 | // TODO: remove this line and configure in plugin when [this PR](https://github.com/webpro-nl/knip/pull/639) will be merged 6 | ignoreDependencies: ['jest-environment-jsdom'], 7 | exclude: ['enumMembers'], 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /src/components/CodeHighlighter.tsx: -------------------------------------------------------------------------------- 1 | import SyntaxHighlighter, { 2 | SyntaxHighlighterProps, 3 | } from 'react-syntax-highlighter'; 4 | import { githubGist } from 'react-syntax-highlighter/dist/esm/styles/hljs'; 5 | 6 | const CodeHighlighter = (props: SyntaxHighlighterProps): JSX.Element => ( 7 | 8 | ); 9 | export default CodeHighlighter; 10 | -------------------------------------------------------------------------------- /src/routes/home/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'wouter'; 2 | 3 | import useEffectOnce from '@/hooks/useEffectOnce'; 4 | import { PATHS } from '@/paths'; 5 | 6 | const Home = (): null => { 7 | const [, setLocation] = useLocation(); 8 | 9 | useEffectOnce(() => { 10 | setLocation(PATHS.COMPUTE_PLANS, { replace: true }); 11 | }); 12 | 13 | return null; 14 | }; 15 | 16 | export default Home; 17 | -------------------------------------------------------------------------------- /charts/substra-frontend/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "substra-frontend.serviceAccountName" . }} 6 | labels: 7 | {{- include "substra-frontend.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /charts/substra-frontend/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: substra-frontend 3 | description: Frontend for Substra 4 | icon: https://avatars.githubusercontent.com/u/84009910?s=400 5 | keywords: 6 | - substra 7 | sources: 8 | - https://github.com/Substra/substra-frontend 9 | type: application 10 | maintainers: 11 | - name: Substra Team 12 | email: support@substra.org 13 | version: 1.2.3 14 | appVersion: 1.0.0 15 | kubeVersion: '>= 1.19.0-0' 16 | -------------------------------------------------------------------------------- /src/components/table/TableTitle.tsx: -------------------------------------------------------------------------------- 1 | import { Heading } from '@chakra-ui/react'; 2 | 3 | type TableTitleProps = { 4 | title: string; 5 | }; 6 | const TableTitle = ({ title }: TableTitleProps): JSX.Element => ( 7 | 13 | {title} 14 | 15 | ); 16 | 17 | export default TableTitle; 18 | -------------------------------------------------------------------------------- /charts/substra-frontend-tests/templates/serviceaccount.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceAccount.create -}} 2 | apiVersion: v1 3 | kind: ServiceAccount 4 | metadata: 5 | name: {{ include "substra-frontend-tests.serviceAccountName" . }} 6 | labels: 7 | {{- include "substra-frontend-tests.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | {{- end }} 13 | -------------------------------------------------------------------------------- /src/api/CPWorkflowApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import API from '@/api/request'; 4 | import { API_PATHS, compilePath } from '@/paths'; 5 | import { TaskGraphT } from '@/types/CPWorkflowTypes'; 6 | 7 | export const retrieveCPWorkflowGraph = ( 8 | key: string, 9 | config: AxiosRequestConfig 10 | ): AxiosPromise => 11 | API.authenticatedGet(compilePath(API_PATHS.WORKFLOW, { key }), config); 12 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true, 6 | "importOrder": [ 7 | "^react$", 8 | "", 9 | "^((?=@chakra-ui)|(?=react-icons))(.*)", 10 | "^@/(?!component)(.*)$", 11 | "^@/components(/)?(.*)$", 12 | "^[./]" 13 | ], 14 | "importOrderSeparation": true, 15 | "plugins": ["@trivago/prettier-plugin-sort-imports"] 16 | } 17 | -------------------------------------------------------------------------------- /charts/substra-frontend/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /src/hooks/useHasPermission.ts: -------------------------------------------------------------------------------- 1 | import useAuthStore from '@/features/auth/useAuthStore'; 2 | import { PermissionT } from '@/types/CommonTypes'; 3 | 4 | const useHasPermission = (): ((permission: PermissionT) => boolean) => { 5 | const { 6 | info: { organization_id: currentNodeID }, 7 | } = useAuthStore(); 8 | 9 | return (permission: PermissionT): boolean => 10 | permission.public || permission.authorized_ids.includes(currentNodeID); 11 | }; 12 | export default useHasPermission; 13 | -------------------------------------------------------------------------------- /src/features/newsFeed/useLastNewsSeen.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorageState } from '@/hooks/useLocalStorageState'; 2 | 3 | const useLastNewsSeen = (): { 4 | lastNewsSeen: string; 5 | setLastNewsSeen: (item: string) => void; 6 | } => { 7 | const [state, setState] = useLocalStorageState( 8 | 'last_news_seen', 9 | '' 10 | ); 11 | 12 | return { 13 | lastNewsSeen: state, 14 | setLastNewsSeen: setState, 15 | }; 16 | }; 17 | 18 | export default useLastNewsSeen; 19 | -------------------------------------------------------------------------------- /src/features/customColumns/CustomColumnsUtils.ts: -------------------------------------------------------------------------------- 1 | import { ColumnT } from './CustomColumnsTypes'; 2 | 3 | export const areColumnsEqual = (a: ColumnT, b: ColumnT): boolean => 4 | a.type === b.type && a.name === b.name; 5 | 6 | export const getColumnId = (column: ColumnT): string => 7 | `${column.type}-${column.name}`; 8 | 9 | export const includesColumn = ( 10 | arrayOfColumns: ColumnT[], 11 | column: ColumnT 12 | ): boolean => 13 | arrayOfColumns.find((c) => areColumnsEqual(c, column)) !== undefined; 14 | -------------------------------------------------------------------------------- /.github/workflows/docker-build.yaml: -------------------------------------------------------------------------------- 1 | name: Docker build 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: [main] 6 | release: 7 | types: [published, edited] 8 | pull_request: 9 | branches: [main] 10 | 11 | concurrency: 12 | group: "${{ github.workflow_ref }} - ${{ github.ref }} - ${{ github.event_name }}" 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | frontend: 17 | uses: substra/substra-gha-workflows/.github/workflows/docker-build.yaml@main 18 | with: 19 | image: substra-frontend 20 | -------------------------------------------------------------------------------- /src/features/newsFeed/NewsFeedUtils.ts: -------------------------------------------------------------------------------- 1 | import { NewsItemAssetKind } from '@/types/NewsFeedTypes'; 2 | 3 | const ASSET_KIND_LABELS: Record = { 4 | ASSET_COMPUTE_PLAN: 'Compute plan', 5 | ASSET_DATA_MANAGER: 'Dataset', 6 | }; 7 | 8 | export const getAssetKindLabel = (assetKind: NewsItemAssetKind): string => { 9 | return ASSET_KIND_LABELS[assetKind]; 10 | }; 11 | 12 | // Interval to actualize unseen news count, important news & refresh banner 13 | export const ACTUALIZE_NEWS_INTERVAL = 60000; // 1min 14 | -------------------------------------------------------------------------------- /src/features/updateAsset/UpdateNameMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItem } from '@chakra-ui/react'; 2 | import { RiPencilLine } from 'react-icons/ri'; 3 | 4 | type UpdateNameMenuItemProps = { 5 | title: string; 6 | openUpdateNameDialog: () => void; 7 | }; 8 | const UpdateNameMenuItem = ({ 9 | title, 10 | openUpdateNameDialog, 11 | }: UpdateNameMenuItemProps) => ( 12 | } onClick={openUpdateNameDialog}> 13 | {title} 14 | 15 | ); 16 | export default UpdateNameMenuItem; 17 | -------------------------------------------------------------------------------- /towncrier.toml: -------------------------------------------------------------------------------- 1 | [tool.towncrier] 2 | directory = "changes" 3 | filename = "CHANGELOG.md" 4 | start_string = "\n" 5 | underlines = ["", "", ""] 6 | title_format = "## [{version}](https://github.com/Substra/substra-frontend/releases/tag/{version}) - {project_date}" 7 | issue_format = "[#{issue}](https://github.com/Substra/substra-frontend/pull/{issue})" 8 | [tool.towncrier.fragment.added] 9 | [tool.towncrier.fragment.removed] 10 | [tool.towncrier.fragment.changed] 11 | [tool.towncrier.fragment.fixed] 12 | -------------------------------------------------------------------------------- /e2e-tests/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "substra-frontend-e2e-tests", 3 | "repository": { 4 | "type": "git", 5 | "url": "https://github.com/Substra/susbtra-frontend.git" 6 | }, 7 | "license": "Apache-2.0", 8 | "private": "true", 9 | "version": "0.1.0", 10 | "scripts": { 11 | "cy:run": "cypress run", 12 | "cy:open": "cypress open" 13 | }, 14 | "dependencies": { 15 | "cypress": "13.12.0" 16 | }, 17 | "engines": { 18 | "node": ">= 18.16" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/types/MeTypes.ts: -------------------------------------------------------------------------------- 1 | import { UserRolesT } from '@/types/UsersTypes'; 2 | 3 | export type MeInfoT = { 4 | host: string; 5 | organization_id: string; 6 | version?: string; 7 | orchestrator_version?: string; 8 | chaincode_version?: string; 9 | channel?: string; 10 | config: { 11 | model_export_enabled?: boolean; 12 | }; 13 | user: string; 14 | user_role: UserRolesT; 15 | auth: MeInfoAuthT; 16 | }; 17 | 18 | type MeInfoAuthT = { 19 | oidc?: { 20 | name: string; 21 | login_url: string; 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ## Description 6 | 7 | 8 | 9 | ## How to test 10 | 11 | 12 | 13 | ## Screenshots 14 | 15 | 16 | 17 | ## Notes for developers and reviewers: 18 | 19 | - Think to update CHANGELOG.md before merge if needed ! 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Substra 8 | 9 | 10 |
11 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/features/cookies/useCookieSettings.ts: -------------------------------------------------------------------------------- 1 | import useCookie, { toBool } from '@/features/cookies/useCookie'; 2 | 3 | const useCookieSettings = (): { 4 | isClarityAccepted: boolean | undefined; 5 | acceptClarity: () => void; 6 | rejectClarity: () => void; 7 | } => { 8 | const [isClarityAccepted, setIsClarityAccepted] = useCookie( 9 | 'isClarityAccepted', 10 | toBool 11 | ); 12 | 13 | return { 14 | isClarityAccepted, 15 | acceptClarity: () => setIsClarityAccepted(true), 16 | rejectClarity: () => setIsClarityAccepted(false), 17 | }; 18 | }; 19 | export default useCookieSettings; 20 | -------------------------------------------------------------------------------- /src/features/copy/CopyButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, useClipboard } from '@chakra-ui/react'; 2 | 3 | type CopyButtonProps = { 4 | value: string; 5 | }; 6 | 7 | const CopyButton = ({ value }: CopyButtonProps): JSX.Element => { 8 | const { hasCopied, onCopy } = useClipboard(value); 9 | 10 | return ( 11 | 20 | ); 21 | }; 22 | 23 | export default CopyButton; 24 | -------------------------------------------------------------------------------- /charts/substra-frontend/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "substra-frontend.fullname" . }} 5 | labels: 6 | {{- include "substra-frontend.labels" . | nindent 4 }} 7 | spec: 8 | type: {{ .Values.service.type }} 9 | ports: 10 | - port: {{ .Values.service.port }} 11 | targetPort: http 12 | protocol: TCP 13 | name: http 14 | {{ if (and (eq .Values.service.type "NodePort") (not (empty .Values.service.nodePort))) }} 15 | nodePort: {{.Values.service.nodePort}} 16 | {{ end }} 17 | selector: 18 | {{- include "substra-frontend.selectorLabels" . | nindent 4 }} 19 | -------------------------------------------------------------------------------- /src/components/IconTag.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Tag } from '@chakra-ui/react'; 2 | 3 | export type IconTagProps = { 4 | icon: React.ElementType; 5 | backgroundColor: string; 6 | fill: string; 7 | }; 8 | const IconTag = ({ 9 | icon, 10 | backgroundColor, 11 | fill, 12 | }: IconTagProps): JSX.Element => ( 13 | 22 | 23 | 24 | ); 25 | export default IconTag; 26 | -------------------------------------------------------------------------------- /src/hooks/useCanDownloadModel.tsx: -------------------------------------------------------------------------------- 1 | import useAuthStore from '@/features/auth/useAuthStore'; 2 | import useHasPermission from '@/hooks/useHasPermission'; 3 | import { PermissionsT } from '@/types/CommonTypes'; 4 | 5 | const useCanDownloadModel = (): ((permissions: PermissionsT) => boolean) => { 6 | const { 7 | info: { 8 | config: { model_export_enabled: modelExportEnabled }, 9 | }, 10 | } = useAuthStore(); 11 | const hasPermission = useHasPermission(); 12 | 13 | return (permissions: PermissionsT): boolean => 14 | !!modelExportEnabled && hasPermission(permissions.download); 15 | }; 16 | export default useCanDownloadModel; 17 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | 3 | import { createRoot } from 'react-dom/client'; 4 | 5 | import { ChakraProvider } from '@chakra-ui/react'; 6 | 7 | import App from '@/App'; 8 | import Fonts from '@/assets/Fonts'; 9 | import theme from '@/assets/chakraTheme'; 10 | 11 | const rootElement = document.getElementById('root'); 12 | if (!rootElement) { 13 | throw new Error('Failed to find the root element'); 14 | } 15 | const root = createRoot(rootElement); 16 | 17 | root.render( 18 | 19 | 20 | 21 | 22 | 23 | 24 | ); 25 | -------------------------------------------------------------------------------- /src/types/UsersTypes.ts: -------------------------------------------------------------------------------- 1 | export enum UserRolesT { 2 | admin = 'ADMIN', 3 | user = 'USER', 4 | } 5 | 6 | export type UserT = { 7 | username: string; 8 | role: UserRolesT; 9 | channel: string; 10 | email: string; 11 | }; 12 | 13 | export type UserPayloadT = { 14 | username: string; 15 | password: string; 16 | role?: UserRolesT; 17 | }; 18 | 19 | export type UpdateUserPayloadT = { 20 | username?: string; 21 | password?: string; 22 | role?: UserRolesT; 23 | }; 24 | 25 | export type ResetPasswordT = { 26 | token: string; 27 | password: string; 28 | }; 29 | 30 | export type UserApprovalPayloadT = { 31 | role: UserRolesT; 32 | }; 33 | -------------------------------------------------------------------------------- /.github/workflows/towncrier-changelog.yml: -------------------------------------------------------------------------------- 1 | name: Towncrier changelog 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | app_version: 7 | type: string 8 | description: 'The version of the app' 9 | required: true 10 | branch: 11 | type: string 12 | description: 'The branch to update' 13 | required: true 14 | 15 | jobs: 16 | test-generate-publish: 17 | uses: substra/substra-gha-workflows/.github/workflows/towncrier-changelog.yml@main 18 | secrets: inherit 19 | with: 20 | app_version: ${{ inputs.app_version }} 21 | repo: substra-frontend 22 | branch: ${{ inputs.branch }} 23 | -------------------------------------------------------------------------------- /src/routes/dataset/components/MoreMenu.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Menu, MenuButton, IconButton, MenuList } from '@chakra-ui/react'; 2 | import { RiMoreLine } from 'react-icons/ri'; 3 | 4 | type MoreMenuProps = { 5 | children: React.ReactNode; 6 | }; 7 | const MoreMenu = ({ children }: MoreMenuProps) => ( 8 | 9 | 10 | } 14 | variant="outline" 15 | size="xs" 16 | /> 17 | {children} 18 | 19 | 20 | ); 21 | 22 | export default MoreMenu; 23 | -------------------------------------------------------------------------------- /src/features/metadata/MetadataUtils.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | import { 4 | MetadataFilterPropsT, 5 | MetadataFilterWithUuidT, 6 | } from '@/types/MetadataTypes'; 7 | 8 | export const addUUID = (filter: MetadataFilterPropsT) => { 9 | return { 10 | uuid: uuidv4(), 11 | // we're not using `...filter` so than we never include extra keys 12 | key: filter.key, 13 | type: filter.type, 14 | value: filter.value, 15 | }; 16 | }; 17 | 18 | export const removeUUID = ( 19 | filter: MetadataFilterWithUuidT 20 | ): MetadataFilterPropsT => ({ 21 | key: filter.key, 22 | type: filter.type, 23 | value: filter.value, 24 | }); 25 | -------------------------------------------------------------------------------- /src/hooks/useKeyPress.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | export function useKeyPress(targetKey: string, callback: () => void) { 4 | const upHandler = useCallback( 5 | ({ key }: KeyboardEvent): void => { 6 | if (key === targetKey) { 7 | callback(); 8 | } 9 | }, 10 | [targetKey, callback] 11 | ); 12 | 13 | // Add event listeners 14 | useEffect(() => { 15 | window.addEventListener('keyup', upHandler); 16 | 17 | // Remove event listeners on cleanup 18 | return () => { 19 | window.removeEventListener('keyup', upHandler); 20 | }; 21 | }, [upHandler]); 22 | } 23 | -------------------------------------------------------------------------------- /src/types/ModelsTypes.ts: -------------------------------------------------------------------------------- 1 | import { FileT, PermissionsT } from '@/types/CommonTypes'; 2 | 3 | export type ModelT = { 4 | key: string; 5 | compute_task_key: string; 6 | owner: string; 7 | creation_date: string; 8 | address?: FileT; 9 | permissions?: PermissionsT; 10 | }; 11 | 12 | export const isModelT = (model: unknown): model is ModelT => { 13 | if (typeof model !== 'object') { 14 | return false; 15 | } 16 | 17 | return ( 18 | (model as ModelT).key !== undefined && 19 | (model as ModelT).compute_task_key !== undefined && 20 | (model as ModelT).owner !== undefined && 21 | (model as ModelT).creation_date !== undefined 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/types/DocsTypes.ts: -------------------------------------------------------------------------------- 1 | export type ReleasesInfoT = { 2 | components: string[]; 3 | releases: ReleaseInfoT[]; 4 | }; 5 | 6 | export type ReleaseInfoT = { 7 | version: string; 8 | components: { 9 | 'substra-backend': ComponentReleaseT; 10 | 'substra-frontend': ComponentReleaseT; 11 | orchestrator: ComponentReleaseT; 12 | substra: ComponentReleaseT; 13 | substrafl: ComponentReleaseT; 14 | 'substra-tools': ComponentReleaseT; 15 | }; 16 | }; 17 | 18 | type ComponentReleaseT = { 19 | version: string; 20 | link: string; 21 | helm?: HelmReleaseInfoT; 22 | }; 23 | 24 | type HelmReleaseInfoT = { 25 | version: string; 26 | link: string; 27 | }; 28 | -------------------------------------------------------------------------------- /src/components/table/AssetsTable.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Table, 3 | TableProps, 4 | Th, 5 | TableColumnHeaderProps, 6 | } from '@chakra-ui/react'; 7 | 8 | export const AssetsTable = (props: TableProps): JSX.Element => ( 9 | 15 | ); 16 | 17 | export const AssetsTablePermissionsTh = ({ 18 | children, 19 | ...props 20 | }: TableColumnHeaderProps) => ( 21 | 37 | ); 38 | export default CheckboxTd; 39 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfCard.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip, Flex, Text, Box } from '@chakra-ui/react'; 2 | 3 | type PerformanceCardProps = { 4 | title: string; 5 | children: React.ReactNode; 6 | onClick: () => void; 7 | }; 8 | 9 | const PerfCard = ({ 10 | title, 11 | children, 12 | onClick, 13 | }: PerformanceCardProps): JSX.Element => { 14 | return ( 15 | 29 | {children} 30 | 31 | 32 | {title} 33 | 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default PerfCard; 40 | -------------------------------------------------------------------------------- /src/components/layout/applayout/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react'; 2 | 3 | import useAuthStore from '@/features/auth/useAuthStore'; 4 | import Actualizer from '@/features/newsFeed/Actualizer'; 5 | 6 | import RefreshBanner from '@/components/RefreshBanner'; 7 | import Header from '@/components/layout/header/Header'; 8 | 9 | type AppLayoutProps = { 10 | children: React.ReactNode; 11 | }; 12 | 13 | const AppLayout = ({ children }: AppLayoutProps): JSX.Element => { 14 | const { authenticated: isAuthenticated } = useAuthStore(); 15 | 16 | return ( 17 | 24 | {isAuthenticated && } 25 | {isAuthenticated && } 26 | {isAuthenticated &&
} 27 | 33 | {children} 34 | 35 | 36 | ); 37 | }; 38 | 39 | export default AppLayout; 40 | -------------------------------------------------------------------------------- /src/routes/tasks/components/TaskIOPermissions.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Icon } from '@chakra-ui/react'; 2 | import { RiGroupLine } from 'react-icons/ri'; 3 | 4 | import { PermissionsT } from '@/types/CommonTypes'; 5 | 6 | import { TaskIOTooltip } from '../TasksUtils'; 7 | 8 | const TaskIOPermissions = ({ 9 | permissions, 10 | }: { 11 | permissions?: PermissionsT | null; 12 | }): JSX.Element => { 13 | let label = ''; 14 | 15 | if (!permissions && permissions !== null) { 16 | label = 'No permissions available yet'; 17 | } else if (permissions === null || permissions?.download.public) { 18 | label = 'Accessible by everyone'; 19 | } else if ( 20 | !permissions.download?.public && 21 | permissions.download?.authorized_ids.length 22 | ) { 23 | label = `Accessible by ${permissions?.download.authorized_ids.join()}`; 24 | } else { 25 | label = 'Accessible by owner only'; 26 | } 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default TaskIOPermissions; 38 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react'; 2 | import path from 'path'; 3 | import { defineConfig } from 'vite'; 4 | import { svgrComponent } from 'vite-plugin-svgr-component'; 5 | 6 | import { version } from './package.json'; 7 | 8 | const APP_VERSION = process.env['APP_VERSION'] || `${version}+dev`; 9 | const MICROSOFT_CLARITY_ID = process.env['MICROSOFT_CLARITY_ID'] || ''; 10 | const API_URL = 11 | process.env['API_URL'] || 'http://substra-backend.org-1.com:8000'; 12 | 13 | // https://vitejs.dev/config/ 14 | export default defineConfig({ 15 | define: { 16 | __APP_VERSION__: `'${APP_VERSION}'`, 17 | ...(process.env.NODE_ENV !== 'production' 18 | ? { 19 | API_URL: `'${API_URL}'`, 20 | MICROSOFT_CLARITY_ID: `'${MICROSOFT_CLARITY_ID}'`, 21 | } 22 | : {}), 23 | DEFAULT_PAGE_SIZE: '30', 24 | 'process.env': {}, 25 | }, 26 | plugins: [react({ jsxImportSource: '@emotion/react' }), svgrComponent()], 27 | resolve: { 28 | alias: { 29 | '@': path.resolve(__dirname, './src'), 30 | }, 31 | }, 32 | server: { 33 | port: 3000, 34 | host: 'substra-frontend.org-1.com', 35 | }, 36 | }); 37 | -------------------------------------------------------------------------------- /src/api/DatasetsApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import API, { getApiOptions } from '@/api/request'; 4 | import { API_PATHS, compilePath } from '@/paths'; 5 | import { APIListArgsT, PaginatedApiResponseT } from '@/types/CommonTypes'; 6 | import { DatasetT, DatasetStubT } from '@/types/DatasetTypes'; 7 | 8 | export const listDatasets = ( 9 | apiListArgs: APIListArgsT, 10 | config: AxiosRequestConfig 11 | ): AxiosPromise> => { 12 | return API.authenticatedGet(API_PATHS.DATASETS, { 13 | ...getApiOptions(apiListArgs), 14 | ...config, 15 | }); 16 | }; 17 | 18 | export const retrieveDataset = ( 19 | key: string, 20 | config: AxiosRequestConfig 21 | ): AxiosPromise => 22 | API.authenticatedGet(compilePath(API_PATHS.DATASET, { key }), config); 23 | 24 | export const retrieveOpener = ( 25 | url: string, 26 | config: AxiosRequestConfig 27 | ): AxiosPromise => API.authenticatedGet(url, config); 28 | 29 | export const updateDataset = ( 30 | key: string, 31 | dataset: { name: string }, 32 | config: AxiosRequestConfig 33 | ): AxiosPromise => 34 | API.put(compilePath(API_PATHS.DATASET, { key }), dataset, config); 35 | -------------------------------------------------------------------------------- /src/types/NewsFeedTypes.ts: -------------------------------------------------------------------------------- 1 | export enum NewsItemStatus { 2 | created = 'STATUS_CREATED', 3 | doing = 'STATUS_DOING', 4 | done = 'STATUS_DONE', 5 | failed = 'STATUS_FAILED', 6 | canceled = 'STATUS_CANCELED', 7 | } 8 | 9 | const NewsItemStatusLabel: Record = { 10 | STATUS_CREATED: 'created', 11 | STATUS_DOING: 'doing', 12 | STATUS_DONE: 'done', 13 | STATUS_FAILED: 'failed', 14 | STATUS_CANCELED: 'canceled', 15 | }; 16 | 17 | export const getNewsItemStatusLabel = (status: NewsItemStatus): string => 18 | NewsItemStatusLabel[status]; 19 | 20 | export enum NewsItemAssetKind { 21 | computePlan = 'ASSET_COMPUTE_PLAN', 22 | dataset = 'ASSET_DATA_MANAGER', 23 | } 24 | 25 | const NewsItemAssetLabel: Record = { 26 | ASSET_COMPUTE_PLAN: 'Compute plan', 27 | ASSET_DATA_MANAGER: 'Dataset', 28 | }; 29 | 30 | export const getNewsItemAssetLabel = (asset_kind: NewsItemAssetKind): string => 31 | NewsItemAssetLabel[asset_kind]; 32 | 33 | export type NewsItemT = { 34 | asset_kind: NewsItemAssetKind; 35 | asset_key: string; 36 | name: string; 37 | status: NewsItemStatus; 38 | timestamp: string; 39 | detail: { 40 | first_failed_task_key?: string; 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/routes/notfound/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'wouter'; 2 | 3 | import { Flex } from '@chakra-ui/react'; 4 | import { RiFileWarningLine } from 'react-icons/ri'; 5 | 6 | import { useDocumentTitleEffect } from '@/hooks/useDocumentTitleEffect'; 7 | import { PATHS } from '@/paths'; 8 | 9 | import EmptyState from '@/components/EmptyState'; 10 | 11 | const NotFound = (): JSX.Element => { 12 | const [, setLocation] = useLocation(); 13 | 14 | useDocumentTitleEffect( 15 | (setDocumentTitle) => setDocumentTitle('Page not found'), 16 | [] 17 | ); 18 | 19 | return ( 20 | 28 | } 30 | title="Oops this page does not exist" 31 | subtitle="You may have mistyped the address or the page may have moved" 32 | buttonLabel="Go back to home" 33 | buttonOnClick={() => setLocation(PATHS.HOME)} 34 | /> 35 | 36 | ); 37 | }; 38 | 39 | export default NotFound; 40 | -------------------------------------------------------------------------------- /src/types/SeriesTypes.ts: -------------------------------------------------------------------------------- 1 | import { ScatterDataPoint } from 'chart.js'; 2 | 3 | export type SerieFeaturesT = { 4 | functionKey: string; 5 | worker: string; 6 | identifier: string; 7 | computePlanKey: string; 8 | }; 9 | 10 | export type PointT = { 11 | rank: number; 12 | round: number; 13 | perf: number | null; 14 | testTaskKey: string | null; 15 | }; 16 | 17 | export type DataPointT = ScatterDataPoint & { 18 | x: number; 19 | y: number; 20 | testTaskKey: string | null; 21 | worker: string; 22 | computePlanKey: string; 23 | serieId: string; 24 | }; 25 | 26 | export type SerieT = SerieFeaturesT & { 27 | id: string; 28 | maxRank: number; 29 | maxRankWithPerf: number; 30 | maxRound: number; 31 | maxRoundWithPerf: number; 32 | points: PointT[]; 33 | }; 34 | 35 | export type HighlightedSerieT = { 36 | id: string; 37 | computePlanKey: string; 38 | }; 39 | 40 | export type HighlightedParamsProps = { 41 | highlightedSerie?: HighlightedSerieT; 42 | highlightedComputePlanKey?: string; 43 | highlightedOrganizationId?: string; 44 | }; 45 | 46 | export type SerieRankDataT = { 47 | id: string; 48 | computePlanKey: string; 49 | testTaskKey: string | null; 50 | worker: string; 51 | perf: string; 52 | }; 53 | -------------------------------------------------------------------------------- /src/features/metadata/useMetadataStore.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { listMetadata } from '@/api/MetadataApi'; 5 | 6 | type MetadataStateT = { 7 | metadata: string[]; 8 | fetchingMetadata: boolean; 9 | fetchMetadata: () => void; 10 | }; 11 | 12 | let fetchController: AbortController | undefined; 13 | 14 | const useMetadataStore = create((set) => ({ 15 | metadata: [], 16 | fetchingMetadata: true, 17 | fetchMetadata: async () => { 18 | // abort previous call 19 | if (fetchController) { 20 | fetchController.abort(); 21 | } 22 | 23 | fetchController = new AbortController(); 24 | set({ fetchingMetadata: true }); 25 | try { 26 | const response = await listMetadata(); 27 | set({ 28 | fetchingMetadata: false, 29 | metadata: response.data, 30 | }); 31 | } catch (error) { 32 | if (axios.isCancel(error)) { 33 | // do nothing, the call has been canceled voluntarily 34 | } else { 35 | console.warn(error); 36 | set({ fetchingMetadata: false }); 37 | } 38 | } 39 | }, 40 | })); 41 | 42 | export default useMetadataStore; 43 | -------------------------------------------------------------------------------- /src/components/table/TablePagination.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, Text } from '@chakra-ui/react'; 2 | 3 | import Pagination from '@/components/table/Pagination'; 4 | 5 | type TablePaginationProps = { 6 | currentPage: number; 7 | itemCount: number; 8 | }; 9 | 10 | const TablePagination = ({ 11 | currentPage, 12 | itemCount, 13 | }: TablePaginationProps): JSX.Element => { 14 | const firstIndex = Math.max((currentPage - 1) * DEFAULT_PAGE_SIZE + 1, 0); 15 | const lastIndex = Math.min(currentPage * DEFAULT_PAGE_SIZE, itemCount); 16 | const lastPage = Math.ceil(itemCount / DEFAULT_PAGE_SIZE); 17 | 18 | return ( 19 | 20 | 26 | {itemCount === 0 && `0 results`} 27 | {itemCount === 1 && 28 | `1 result • ${firstIndex}-${lastIndex} shown`} 29 | {itemCount > 1 && 30 | `${itemCount} results • ${firstIndex}-${lastIndex} shown`} 31 | 32 | 33 | 34 | ); 35 | }; 36 | export default TablePagination; 37 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfChartTooltipItem.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { HStack, ListItem, Text } from '@chakra-ui/react'; 4 | 5 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser'; 6 | import { DataPointT } from '@/types/SeriesTypes'; 7 | 8 | import PerfIconTag from '@/components/PerfIconTag'; 9 | 10 | const PerfChartTooltipItem = ({ 11 | point, 12 | }: { 13 | point: DataPointT; 14 | }): JSX.Element => { 15 | const { getSerieIndex } = useContext(PerfBrowserContext); 16 | 17 | return ( 18 | 23 | 24 | 28 | 29 | {`#${getSerieIndex( 30 | point.computePlanKey, 31 | point.serieId 32 | )} • ${point.worker}`} 33 | 34 | 35 | {point.y.toFixed(3)} 36 | 37 | ); 38 | }; 39 | 40 | export default PerfChartTooltipItem; 41 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: ['*'] 5 | # FYI this isn't triggered when more than 3 tags are pushed at once 6 | # https://docs.github.com/en/developers/webhooks-and-events/webhook-events-and-payloads#push 7 | env: 8 | REGISTRY: ghcr.io 9 | jobs: 10 | issue-release: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - uses: docker/login-action@v3 16 | with: 17 | registry: ${{ env.REGISTRY }} 18 | username: ${{ github.actor }} 19 | password: ${{ secrets.GITHUB_TOKEN }} 20 | 21 | - uses: docker/metadata-action@v5 22 | id: docker-metadata 23 | with: 24 | images: 'ghcr.io/substra/substra-frontend' 25 | tags: | 26 | type=ref,event=tag 27 | type=raw,value=latest 28 | 29 | - uses: docker/build-push-action@v6 30 | with: 31 | push: ${{ github.event_name != 'pull_request' }} 32 | file: ./docker/substra-frontend/Dockerfile 33 | context: . 34 | tags: ${{ steps.docker-metadata.outputs.tags }} 35 | labels: ${{ steps.docker-metadata.outputs.labels }} 36 | -------------------------------------------------------------------------------- /src/routes/tasks/useTasksStore.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { listTasks } from '@/api/TasksApi'; 5 | import { withAbortSignal } from '@/api/request'; 6 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes'; 7 | import { TaskT } from '@/types/TasksTypes'; 8 | 9 | type TasksStateT = { 10 | tasks: TaskT[]; 11 | tasksCount: number; 12 | fetchingTasks: boolean; 13 | fetchTasks: (params: APIListArgsT) => AbortFunctionT; 14 | }; 15 | 16 | const useTasksStore = create((set) => ({ 17 | tasks: [], 18 | tasksCount: 0, 19 | fetchingTasks: true, 20 | fetchTasks: withAbortSignal(async (signal, params) => { 21 | set({ fetchingTasks: true }); 22 | try { 23 | const response = await listTasks(params, { 24 | signal, 25 | }); 26 | set({ 27 | fetchingTasks: false, 28 | tasks: response.data.results, 29 | tasksCount: response.data.count, 30 | }); 31 | } catch (error) { 32 | if (axios.isCancel(error)) { 33 | // do nothing, the call has been canceled voluntarily 34 | } else { 35 | console.warn(error); 36 | set({ fetchingTasks: false }); 37 | } 38 | } 39 | }), 40 | })); 41 | 42 | export default useTasksStore; 43 | -------------------------------------------------------------------------------- /e2e-tests/cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | // Import commands.js using ES2015 syntax: 16 | import './commands'; 17 | 18 | // Alternatively you can use CommonJS syntax: 19 | // require('./commands') 20 | 21 | beforeEach(() => { 22 | cy.request( 23 | 'POST', 24 | `${Cypress.env('BACKEND_API_URL')}/me/login/?format=json`, 25 | { 26 | username: Cypress.env('USERNAME'), 27 | password: Cypress.env('PASSWORD'), 28 | } 29 | ); 30 | 31 | /** 32 | * keep uncaught exceptions from failing tests 33 | * uncomment this function to test part with expected error 34 | **/ 35 | // cy.on('uncaught:exception', (e, runnable) => { 36 | // console.log('error', e); 37 | // console.log('runnable', runnable); 38 | // console.log('error', e.message); 39 | // return false; 40 | // }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/components/Duration.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Icon, Text } from '@chakra-ui/react'; 2 | import { RiTimeLine } from 'react-icons/ri'; 3 | 4 | import { formatDuration, getDiffDates } from '@/libs/utils'; 5 | import { 6 | ComputePlanStatus, 7 | ComputePlanT, 8 | isComputePlan, 9 | } from '@/types/ComputePlansTypes'; 10 | import { TaskT } from '@/types/TasksTypes'; 11 | 12 | type DurationProps = { 13 | asset: ComputePlanT | TaskT; 14 | }; 15 | 16 | const Duration = ({ asset }: DurationProps): JSX.Element | null => { 17 | if (!asset.start_date) { 18 | return null; 19 | } 20 | 21 | return ( 22 | 23 | 24 | {formatDuration(asset.duration)} 25 | {isComputePlan(asset) && 26 | asset.status === ComputePlanStatus.doing && 27 | asset.estimated_end_date && ( 28 | <> 29 | 30 | 31 | {`${getDiffDates( 32 | 'now', 33 | asset.estimated_end_date 34 | )} remaining`} 35 | 36 | 37 | )} 38 | 39 | ); 40 | }; 41 | export default Duration; 42 | -------------------------------------------------------------------------------- /skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2beta16 2 | kind: Config 3 | build: 4 | artifacts: 5 | - image: &imageref substra/substra-frontend 6 | context: . 7 | docker: 8 | dockerfile: docker/substra-frontend/Dockerfile 9 | 10 | deploy: 11 | helm: 12 | releases: 13 | - name: frontend-org-1 14 | chartPath: charts/substra-frontend 15 | namespace: org-1 16 | createNamespace: true 17 | artifactOverrides: 18 | image: *imageref 19 | imageStrategy: 20 | helm: 21 | explicitRegistry: true 22 | valuesFiles: 23 | - 'skaffold-values/org-1.yaml' 24 | 25 | - name: frontend-org-2 26 | chartPath: charts/substra-frontend 27 | namespace: org-2 28 | createNamespace: true 29 | artifactOverrides: 30 | image: *imageref 31 | imageStrategy: 32 | helm: 33 | explicitRegistry: true 34 | valuesFiles: 35 | - 'skaffold-values/org-2.yaml' 36 | 37 | profiles: 38 | - name: single-org 39 | patches: 40 | - op: remove 41 | path: /deploy/helm/releases/1 42 | - name: dev 43 | patches: 44 | - op: add 45 | path: /build/artifacts/0/docker/target 46 | value: dev 47 | -------------------------------------------------------------------------------- /src/routes/computePlanDetails/ComputePlanUtils.ts: -------------------------------------------------------------------------------- 1 | import { ComputePlanT } from '@/types/ComputePlansTypes'; 2 | import { TaskStatus } from '@/types/TasksTypes'; 3 | 4 | export const getStatusCount = ( 5 | computePlan: ComputePlanT, 6 | status: TaskStatus 7 | ): number => { 8 | if (status === TaskStatus.executing) { 9 | return computePlan.executing_count; 10 | } else if (status === TaskStatus.done) { 11 | return computePlan.done_count; 12 | } else if (status === TaskStatus.canceled) { 13 | return computePlan.canceled_count; 14 | } else if (status === TaskStatus.failed) { 15 | return computePlan.failed_count; 16 | } else if (status === TaskStatus.waitingParentTasks) { 17 | return computePlan.waiting_parent_tasks_count; 18 | } else if (status === TaskStatus.waitingExecutorSlot) { 19 | return computePlan.waiting_executor_slot_count; 20 | } else if (status === TaskStatus.waitingBuilderSlot) { 21 | return computePlan.waiting_builder_slot_count; 22 | } else if (status === TaskStatus.building) { 23 | return computePlan.building_count; 24 | } 25 | 26 | throw `Invalid status ${status}`; 27 | }; 28 | 29 | export const compareComputePlans = ( 30 | a: ComputePlanT, 31 | b: ComputePlanT 32 | ): -1 | 0 | 1 => { 33 | if (a.key < b.key) { 34 | return -1; 35 | } else if (a.key === b.key) { 36 | return 0; 37 | } else { 38 | return 1; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/features/organizations/useOrganizationsStore.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { listOrganizations } from '@/api/OrganizationsApi'; 5 | import { OrganizationT } from '@/types/OrganizationsTypes'; 6 | 7 | type OrganizationsStateT = { 8 | organizations: OrganizationT[]; 9 | fetchingOrganizations: boolean; 10 | fetchOrganizations: () => void; 11 | }; 12 | 13 | let fetchController: AbortController | undefined; 14 | 15 | const useOrganizationsStore = create((set) => ({ 16 | organizations: [], 17 | fetchingOrganizations: true, 18 | fetchOrganizations: async () => { 19 | // abort previous call 20 | if (fetchController) { 21 | fetchController.abort(); 22 | } 23 | 24 | fetchController = new AbortController(); 25 | set({ fetchingOrganizations: true }); 26 | try { 27 | const response = await listOrganizations(); 28 | set({ 29 | fetchingOrganizations: false, 30 | organizations: response.data, 31 | }); 32 | } catch (error) { 33 | if (axios.isCancel(error)) { 34 | // do nothing, the call has been canceled voluntarily 35 | } else { 36 | console.warn(error); 37 | set({ fetchingOrganizations: false }); 38 | } 39 | } 40 | }, 41 | })); 42 | 43 | export default useOrganizationsStore; 44 | -------------------------------------------------------------------------------- /src/routes/datasets/useDatasetsStores.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { listDatasets } from '@/api/DatasetsApi'; 5 | import { withAbortSignal } from '@/api/request'; 6 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes'; 7 | import { DatasetStubT } from '@/types/DatasetTypes'; 8 | 9 | type DatasetsStateT = { 10 | datasets: DatasetStubT[]; 11 | datasetsCount: number; 12 | fetchingDatasets: boolean; 13 | fetchDatasets: (params: APIListArgsT) => AbortFunctionT; 14 | }; 15 | 16 | const useDatasetsStore = create((set) => ({ 17 | datasets: [], 18 | datasetsCount: 0, 19 | fetchingDatasets: true, 20 | fetchDatasets: withAbortSignal(async (signal, params) => { 21 | set({ fetchingDatasets: true }); 22 | try { 23 | const response = await listDatasets(params, { 24 | signal, 25 | }); 26 | set({ 27 | fetchingDatasets: false, 28 | datasets: response.data.results, 29 | datasetsCount: response.data.count, 30 | }); 31 | } catch (error) { 32 | if (axios.isCancel(error)) { 33 | // do nothing, the call has been canceled voluntarily 34 | } else { 35 | console.warn(error); 36 | set({ fetchingDatasets: false }); 37 | } 38 | } 39 | }), 40 | })); 41 | 42 | export default useDatasetsStore; 43 | -------------------------------------------------------------------------------- /src/routes/users/UsersUtils.ts: -------------------------------------------------------------------------------- 1 | import { UserRolesT } from '@/types/UsersTypes'; 2 | 3 | export const UserRolesToLabel: Record = { 4 | [UserRolesT.admin]: 'Admin', 5 | [UserRolesT.user]: 'User', 6 | }; 7 | 8 | export const isDifferentFromUsername = ( 9 | password: string, 10 | username: string 11 | ): boolean => { 12 | return password !== username; 13 | }; 14 | 15 | export const hasCorrectLength = (password: string): boolean => { 16 | return password.length >= 20 && password.length <= 64; 17 | }; 18 | 19 | export const hasSpecialChar = (password: string): boolean => { 20 | const regexSpecialChar = /[^A-Za-z0-9]/; 21 | return !!password.match(regexSpecialChar)?.length; 22 | }; 23 | 24 | export const hasNumber = (password: string): boolean => { 25 | const regexNumber = /[0-9]/; 26 | return !!password.match(regexNumber); 27 | }; 28 | 29 | export const hasLowerAndUpperChar = (password: string): boolean => { 30 | const regexLowerChar = /[a-z]/; 31 | const regexUpperChar = /[A-Z]/; 32 | 33 | return !!password.match(regexLowerChar) && !!password.match(regexUpperChar); 34 | }; 35 | 36 | export const isPasswordValid = ( 37 | password: string, 38 | username: string 39 | ): boolean => { 40 | return ( 41 | isDifferentFromUsername(password, username) && 42 | hasCorrectLength(password) && 43 | hasSpecialChar(password) && 44 | hasNumber(password) && 45 | hasLowerAndUpperChar(password) 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/routes/functions/useFunctionsStore.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { listFunctions } from '@/api/FunctionsApi'; 5 | import { withAbortSignal } from '@/api/request'; 6 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes'; 7 | import { FunctionT } from '@/types/FunctionsTypes'; 8 | 9 | type FunctionsStateT = { 10 | functions: FunctionT[]; 11 | functionsCount: number; 12 | fetchingFunctions: boolean; 13 | fetchFunctions: (params: APIListArgsT) => AbortFunctionT; 14 | }; 15 | 16 | const useFunctionsStore = create((set) => ({ 17 | functions: [], 18 | functionsCount: 0, 19 | fetchingFunctions: true, 20 | fetchFunctions: withAbortSignal(async (signal, params) => { 21 | set({ fetchingFunctions: true }); 22 | try { 23 | const response = await listFunctions(params, { 24 | signal, 25 | }); 26 | set({ 27 | fetchingFunctions: false, 28 | functions: response.data.results, 29 | functionsCount: response.data.count, 30 | }); 31 | } catch (error) { 32 | if (axios.isCancel(error)) { 33 | // do nothing, call has been canceled voluntarily 34 | } else { 35 | console.warn(error); 36 | set({ fetchingFunctions: false }); 37 | } 38 | } 39 | }), 40 | })); 41 | 42 | export default useFunctionsStore; 43 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/menu.cy.js: -------------------------------------------------------------------------------- 1 | describe('Menu tests', () => { 2 | before(() => { 3 | cy.login(); 4 | }); 5 | 6 | beforeEach(() => { 7 | cy.visit('/compute_plans'); 8 | cy.getDataCy('menu-button').click(); 9 | }); 10 | 11 | it('help and feedback modal', () => { 12 | cy.getDataCy('help').click(); 13 | cy.getDataCy('help-modal').should('exist'); 14 | }); 15 | 16 | it('about modal', () => { 17 | cy.getDataCy('about').click(); 18 | cy.getDataCy('about-modal').should('exist'); 19 | }); 20 | 21 | it('documentation link', () => { 22 | cy.getDataCy('documentation') 23 | .should('have.attr', 'href', 'https://docs.substra.org/') 24 | .should('have.attr', 'target', '_blank'); 25 | }); 26 | 27 | it('api tokens page', () => { 28 | cy.getDataCy('api-tokens').click(); 29 | cy.url().should('include', '/manage_tokens'); 30 | }); 31 | 32 | it('users management page', () => { 33 | cy.get('[data-user-role]') 34 | .invoke('data', 'user-role') 35 | .then((userRole) => { 36 | if (userRole === 'ADMIN') { 37 | cy.getDataCy('users-management').click(); 38 | cy.url().should('include', '/users'); 39 | } 40 | }); 41 | }); 42 | 43 | it('logout button', () => { 44 | cy.getDataCy('logout').click(); 45 | cy.url().should('include', '/login'); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/components/DownloadIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconButton, 3 | IconButtonProps, 4 | Tooltip, 5 | TooltipProps, 6 | } from '@chakra-ui/react'; 7 | import { RiDownloadLine } from 'react-icons/ri'; 8 | 9 | import { downloadBlob, downloadFromApi } from '@/api/request'; 10 | 11 | type DownloadIconButtonProps = IconButtonProps & { 12 | storageAddress?: string; 13 | blob?: Blob; 14 | filename: string; 15 | placement?: TooltipProps['placement']; 16 | }; 17 | const DownloadIconButton = ({ 18 | storageAddress, 19 | blob, 20 | filename, 21 | placement, 22 | ...props 23 | }: DownloadIconButtonProps): JSX.Element => { 24 | const download = () => { 25 | if (storageAddress) { 26 | downloadFromApi(storageAddress, filename); 27 | } else if (blob) { 28 | downloadBlob(blob, filename); 29 | } else { 30 | console.error('No url or content specified for download'); 31 | } 32 | }; 33 | return ( 34 | 40 | } 45 | onClick={download} 46 | {...props} 47 | /> 48 | 49 | ); 50 | }; 51 | export default DownloadIconButton; 52 | -------------------------------------------------------------------------------- /src/types/DatasetTypes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FileT, 3 | MetadataT, 4 | PermissionsT, 5 | PermissionT, 6 | } from '@/types/CommonTypes'; 7 | 8 | // DatasetStubT is returned when fetching a list of datasets 9 | export type DatasetStubT = { 10 | key: string; 11 | name: string; 12 | owner: string; 13 | permissions: PermissionsT; 14 | logs_permission: PermissionT; 15 | description: FileT; 16 | opener: FileT; 17 | type: string; 18 | creation_date: string; 19 | metadata: MetadataT; 20 | }; 21 | 22 | // DatasetT is returned when fetching a single dataset 23 | export type DatasetT = DatasetStubT & { 24 | data_sample_keys: string[]; 25 | }; 26 | 27 | export const isDatasetStubT = ( 28 | datasetStub: unknown 29 | ): datasetStub is DatasetStubT => { 30 | if (typeof datasetStub !== 'object') { 31 | return false; 32 | } 33 | 34 | return ( 35 | (datasetStub as DatasetStubT).key !== undefined && 36 | (datasetStub as DatasetStubT).name !== undefined && 37 | (datasetStub as DatasetStubT).owner !== undefined && 38 | (datasetStub as DatasetStubT).permissions !== undefined && 39 | (datasetStub as DatasetStubT).logs_permission !== undefined && 40 | (datasetStub as DatasetStubT).description !== undefined && 41 | (datasetStub as DatasetStubT).opener !== undefined && 42 | (datasetStub as DatasetStubT).type !== undefined && 43 | (datasetStub as DatasetStubT).creation_date !== undefined && 44 | (datasetStub as DatasetStubT).metadata !== undefined 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfList.tsx: -------------------------------------------------------------------------------- 1 | import { VStack, Wrap, WrapItem } from '@chakra-ui/react'; 2 | 3 | import PerfCard from '@/features/perfBrowser/PerfCard'; 4 | import PerfChart from '@/features/perfBrowser/PerfChart'; 5 | import PerfEmptyState from '@/features/perfBrowser/PerfEmptyState'; 6 | import { SerieT } from '@/types/SeriesTypes'; 7 | 8 | type PerfListProps = { 9 | seriesGroups: SerieT[][]; 10 | onCardClick: (identifier: string) => void; 11 | }; 12 | const PerfList = ({ seriesGroups, onCardClick }: PerfListProps) => { 13 | return ( 14 | 24 | 25 | 26 | {seriesGroups.map((series) => ( 27 | 28 | onCardClick(series[0].identifier)} 31 | > 32 | 33 | 34 | 35 | ))} 36 | 37 | 38 | ); 39 | }; 40 | export default PerfList; 41 | -------------------------------------------------------------------------------- /src/features/customColumns/useCustomColumns.ts: -------------------------------------------------------------------------------- 1 | import { useLocalStorageArrayState } from '@/hooks/useLocalStorageState'; 2 | 3 | import { ColumnT, GENERAL_COLUMNS, isColumn } from './CustomColumnsTypes'; 4 | import { areColumnsEqual } from './CustomColumnsUtils'; 5 | 6 | const migrate = (data: unknown): ColumnT[] => { 7 | // custom columns used to be stored as a json array of strings matching metadata names 8 | if (!Array.isArray(data)) { 9 | return []; 10 | } 11 | 12 | // migrate old data 13 | let oldDataFound = false; 14 | const migratedData = data.map((item) => { 15 | if (typeof item === 'string') { 16 | oldDataFound = true; 17 | return { name: item, type: 'metadata' }; 18 | } else { 19 | return item; 20 | } 21 | }); 22 | 23 | const columns = migratedData.filter(isColumn); 24 | 25 | if (oldDataFound) { 26 | return [...GENERAL_COLUMNS, ...columns]; 27 | } else { 28 | return columns; 29 | } 30 | }; 31 | 32 | const useCustomColumns = (): { 33 | columns: ColumnT[]; 34 | setColumns: (columns: ColumnT[]) => void; 35 | clearColumns: () => void; 36 | } => { 37 | const { 38 | state: columns, 39 | setState: setColumns, 40 | clearState: clearColumns, 41 | } = useLocalStorageArrayState( 42 | 'custom_columns', 43 | areColumnsEqual, 44 | migrate, 45 | GENERAL_COLUMNS 46 | ); 47 | 48 | return { 49 | columns, 50 | setColumns, 51 | clearColumns, 52 | }; 53 | }; 54 | 55 | export default useCustomColumns; 56 | -------------------------------------------------------------------------------- /src/components/Status.tsx: -------------------------------------------------------------------------------- 1 | import { Tag, TagLabel, TagLeftIcon, TagProps, Text } from '@chakra-ui/react'; 2 | 3 | import { getStatusLabel, getStatusStyle } from '@/libs/status'; 4 | import { ComputePlanStatus } from '@/types/ComputePlansTypes'; 5 | import { TaskStatus } from '@/types/TasksTypes'; 6 | 7 | type StatusProps = { 8 | status: ComputePlanStatus | TaskStatus; 9 | size: TagProps['size']; 10 | variant?: TagProps['variant']; 11 | withIcon?: boolean; 12 | count?: number; 13 | }; 14 | 15 | const Status = ({ 16 | status, 17 | size, 18 | variant, 19 | withIcon, 20 | count, 21 | }: StatusProps): JSX.Element => { 22 | const { 23 | icon, 24 | tagColor, 25 | tagBackgroundColor, 26 | tagSolidColor, 27 | tagSolidBackgroundColor, 28 | } = getStatusStyle(status); 29 | const label = getStatusLabel(status); 30 | const color = variant === 'solid' ? tagSolidColor : tagColor; 31 | const backgroundColor = 32 | variant === 'solid' ? tagSolidBackgroundColor : tagBackgroundColor; 33 | 34 | return ( 35 | 41 | {withIcon !== false && } 42 | 43 | 44 | {label} 45 | 46 | {count !== undefined && ` • ${count}`} 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default Status; 53 | -------------------------------------------------------------------------------- /src/routes/computePlans/components/FavoriteBox.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | 5 | import { Box } from '@chakra-ui/react'; 6 | import { RiStarFill, RiStarLine } from 'react-icons/ri'; 7 | 8 | const StyledInput = styled('input')` 9 | border: 0px; 10 | clip: rect(0px, 0px, 0px, 0px); 11 | height: 1px; 12 | width: 1px; 13 | margin: -1px; 14 | padding: 0px; 15 | overflow: hidden; 16 | white-space: nowrap; 17 | position: absolute; 18 | `; 19 | 20 | type FavoriteBoxProps = { 21 | isChecked: boolean; 22 | onChange: () => void; 23 | }; 24 | const FavoriteBox = ({ 25 | isChecked, 26 | onChange, 27 | }: FavoriteBoxProps): JSX.Element => { 28 | const [focus, setFocus] = useState(false); 29 | return ( 30 | 37 | {!isChecked && } 38 | {isChecked && ( 39 | 43 | )} 44 | setFocus(true)} 48 | onBlur={() => setFocus(false)} 49 | /> 50 | 51 | ); 52 | }; 53 | 54 | export default FavoriteBox; 55 | -------------------------------------------------------------------------------- /src/routes/computePlanDetails/workflow/useWorkflowStore.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { retrieveCPWorkflowGraph } from '@/api/CPWorkflowApi'; 5 | import { handleUnknownError, withAbortSignal } from '@/api/request'; 6 | import { TaskGraphT } from '@/types/CPWorkflowTypes'; 7 | import { AbortFunctionT } from '@/types/CommonTypes'; 8 | 9 | type WorkflowStateT = { 10 | graph: TaskGraphT; 11 | fetchingGraph: boolean; 12 | graphError: string | null; 13 | fetchGraph: (computePlanKey: string) => AbortFunctionT; 14 | }; 15 | 16 | const emptyGraph = { 17 | tasks: [], 18 | edges: [], 19 | }; 20 | 21 | const useWorkflowStore = create((set) => ({ 22 | graph: emptyGraph, 23 | fetchingGraph: true, 24 | graphError: null, 25 | fetchGraph: withAbortSignal(async (signal, computePlanKey) => { 26 | set({ fetchingGraph: true, graph: emptyGraph, graphError: null }); 27 | try { 28 | const response = await retrieveCPWorkflowGraph(computePlanKey, { 29 | signal, 30 | }); 31 | set({ 32 | fetchingGraph: false, 33 | graph: response.data, 34 | }); 35 | } catch (error) { 36 | if (axios.isCancel(error)) { 37 | // do nothing, the call has been canceled voluntarily 38 | } else { 39 | console.warn(error); 40 | const graphError = handleUnknownError(error); 41 | set({ fetchingGraph: false, graphError }); 42 | } 43 | } 44 | }), 45 | })); 46 | 47 | export default useWorkflowStore; 48 | -------------------------------------------------------------------------------- /src/components/PerfIconTag.tsx: -------------------------------------------------------------------------------- 1 | import { Icon, Tag } from '@chakra-ui/react'; 2 | import { RiGitCommitLine } from 'react-icons/ri'; 3 | 4 | import usePerfBrowserColors from '@/features/perfBrowser/usePerfBrowserColors'; 5 | import usePerfBrowserPointStyles from '@/features/perfBrowser/usePerfBrowserPointStyles'; 6 | 7 | type PerfIconTagProps = { 8 | worker: string; 9 | computePlanKey: string; 10 | }; 11 | const PerfIconTag = ({ 12 | worker, 13 | computePlanKey, 14 | }: PerfIconTagProps): JSX.Element => { 15 | const { getColorScheme } = usePerfBrowserColors(); 16 | const { getPointStyleComponent } = usePerfBrowserPointStyles(); 17 | 18 | const IconComponent = getPointStyleComponent(worker); 19 | const colorScheme = getColorScheme({ worker, computePlanKey }); 20 | 21 | return ( 22 | 35 | 41 | 42 | ); 43 | }; 44 | export default PerfIconTag; 45 | -------------------------------------------------------------------------------- /src/hooks/useBuildPerfChartDataset.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | import { ChartDataset } from 'chart.js'; 4 | 5 | import { XAxisModeT } from '@/features/perfBrowser/usePerfBrowser'; 6 | import usePerfChartDatasetStyle from '@/features/perfBrowser/usePerfChartDatasetStyle'; 7 | import { 8 | DataPointT, 9 | HighlightedParamsProps, 10 | SerieT, 11 | } from '@/types/SeriesTypes'; 12 | 13 | type PerfChartDatasetProps = ChartDataset<'line', DataPointT[]>; 14 | 15 | const useBuildPerfChartDataset = (): (( 16 | serie: SerieT, 17 | xAxisMode: XAxisModeT, 18 | highlightedParams: HighlightedParamsProps 19 | ) => PerfChartDatasetProps) => { 20 | const datasetStyle = usePerfChartDatasetStyle(); 21 | 22 | return useCallback( 23 | ( 24 | serie: SerieT, 25 | xAxisMode: XAxisModeT, 26 | highlightedParams: HighlightedParamsProps 27 | ): PerfChartDatasetProps => { 28 | return { 29 | label: serie.id, 30 | data: serie.points.map( 31 | (point): DataPointT => ({ 32 | x: point[xAxisMode], 33 | y: point.perf as number, 34 | testTaskKey: point.testTaskKey, 35 | worker: serie.worker, 36 | computePlanKey: serie.computePlanKey, 37 | serieId: serie.id, 38 | }) 39 | ), 40 | parsing: false, 41 | ...datasetStyle(serie, highlightedParams), 42 | }; 43 | }, 44 | [datasetStyle] 45 | ); 46 | }; 47 | export default useBuildPerfChartDataset; 48 | -------------------------------------------------------------------------------- /src/api/TasksApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import API, { getApiOptions } from '@/api/request'; 4 | import { API_PATHS, compilePath } from '@/paths'; 5 | import { APIListArgsT, PaginatedApiResponseT } from '@/types/CommonTypes'; 6 | import { TaskT, TaskIOT } from '@/types/TasksTypes'; 7 | 8 | export const listTasks = ( 9 | apiListArgs: APIListArgsT, 10 | config: AxiosRequestConfig 11 | ): AxiosPromise> => 12 | API.authenticatedGet(API_PATHS.TASKS, { 13 | ...getApiOptions(apiListArgs), 14 | ...config, 15 | }); 16 | 17 | export const retrieveTask = ( 18 | key: string, 19 | config: AxiosRequestConfig 20 | ): AxiosPromise => 21 | API.authenticatedGet(compilePath(API_PATHS.TASK, { key }), config); 22 | 23 | export const listTaskInputAssets = ( 24 | key: string, 25 | apiListArgs: APIListArgsT, 26 | config: AxiosRequestConfig 27 | ): AxiosPromise> => 28 | API.authenticatedGet(compilePath(API_PATHS.TASK_INPUTS, { key }), { 29 | ...getApiOptions(apiListArgs), 30 | ...config, 31 | }); 32 | 33 | export const listTaskOutputAssets = ( 34 | key: string, 35 | apiListArgs: APIListArgsT, 36 | config: AxiosRequestConfig 37 | ): AxiosPromise> => 38 | API.authenticatedGet(compilePath(API_PATHS.TASK_OUTPUTS, { key }), { 39 | ...getApiOptions(apiListArgs), 40 | ...config, 41 | }); 42 | 43 | export const retrieveLogs = ( 44 | key: string, 45 | config: AxiosRequestConfig 46 | ): AxiosPromise => 47 | API.authenticatedGet(compilePath(API_PATHS.LOGS, { key }), config); 48 | -------------------------------------------------------------------------------- /charts/substra-frontend/templates/NOTES.txt: -------------------------------------------------------------------------------- 1 | 1. Get the application URL by running these commands: 2 | {{- if .Values.ingress.enabled }} 3 | {{- range $host := .Values.ingress.hosts }} 4 | {{- range .paths }} 5 | http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ . }} 6 | {{- end }} 7 | {{- end }} 8 | {{- else if contains "NodePort" .Values.service.type }} 9 | export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "substra-frontend.fullname" . }}) 10 | export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") 11 | echo http://$NODE_IP:$NODE_PORT 12 | {{- else if contains "LoadBalancer" .Values.service.type }} 13 | NOTE: It may take a few minutes for the LoadBalancer IP to be available. 14 | You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "substra-frontend.fullname" . }}' 15 | export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "substra-frontend.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") 16 | echo http://$SERVICE_IP:{{ .Values.service.port }} 17 | {{- else if contains "ClusterIP" .Values.service.type }} 18 | export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "substra-frontend.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") 19 | echo "Visit http://127.0.0.1:8080 to use your application" 20 | kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:80 21 | {{- end }} 22 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfSidebarSettings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Collapse, 4 | Flex, 5 | Heading, 6 | Icon, 7 | useDisclosure, 8 | VStack, 9 | } from '@chakra-ui/react'; 10 | import { RiArrowDropDownLine } from 'react-icons/ri'; 11 | 12 | import PerfSidebarSettingsOrganizations from '@/features/perfBrowser/PerfSidebarSettingsOrganizations'; 13 | import PerfSidebarSettingsUnits from '@/features/perfBrowser/PerfSidebarSettingsUnits'; 14 | 15 | const PerfSidebarSettings = (): JSX.Element => { 16 | const { isOpen, onToggle } = useDisclosure({ 17 | defaultIsOpen: true, 18 | }); 19 | 20 | return ( 21 | 22 | 23 | 28 | Settings 29 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | 47 | export default PerfSidebarSettings; 48 | -------------------------------------------------------------------------------- /src/components/MetadataModalTr.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { HStack, Td, Text, Th, Tr } from '@chakra-ui/react'; 4 | 5 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser'; 6 | import { ComputePlanT } from '@/types/ComputePlansTypes'; 7 | 8 | type MetadataModalTrProps = { 9 | computePlan: ComputePlanT; 10 | columns: string[]; 11 | }; 12 | 13 | const MetadataModalTr = ({ 14 | computePlan, 15 | columns, 16 | }: MetadataModalTrProps): JSX.Element => { 17 | const { getComputePlanIndex, computePlans } = 18 | useContext(PerfBrowserContext); 19 | return ( 20 |
21 | 38 | {columns.map((column) => ( 39 | 46 | ))} 47 | 48 | ); 49 | }; 50 | 51 | export default MetadataModalTr; 52 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfEmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Flex } from '@chakra-ui/react'; 4 | import { RiFunctionLine } from 'react-icons/ri'; 5 | 6 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser'; 7 | import { SerieT } from '@/types/SeriesTypes'; 8 | 9 | import EmptyState from '@/components/EmptyState'; 10 | 11 | const PerfEmptyState = ({ 12 | seriesGroups, 13 | }: { 14 | seriesGroups: SerieT[][]; 15 | }): JSX.Element | null => { 16 | const { series } = useContext(PerfBrowserContext); 17 | if (series.length === 0) { 18 | return ( 19 | 25 | } 29 | /> 30 | 31 | ); 32 | } 33 | if (seriesGroups.length === 0) { 34 | return ( 35 | 41 | } 45 | /> 46 | 47 | ); 48 | } 49 | return null; 50 | }; 51 | export default PerfEmptyState; 52 | -------------------------------------------------------------------------------- /src/routes/compare/useCompareStore.tsx: -------------------------------------------------------------------------------- 1 | import axios, { AxiosPromise } from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { retrieveComputePlan } from '@/api/ComputePlansApi'; 5 | import { withAbortSignal } from '@/api/request'; 6 | import { AbortFunctionT } from '@/types/CommonTypes'; 7 | import { ComputePlanT } from '@/types/ComputePlansTypes'; 8 | 9 | type CompareStateT = { 10 | computePlans: ComputePlanT[]; 11 | fetchingComputePlans: boolean; 12 | fetchComputePlans: (computePlanKeys: string[]) => AbortFunctionT; 13 | }; 14 | 15 | const useCompareStore = create((set) => ({ 16 | computePlans: [], 17 | fetchingComputePlans: true, 18 | fetchComputePlans: withAbortSignal(async (signal, computePlanKeys) => { 19 | set({ fetchingComputePlans: true }); 20 | let promises: AxiosPromise[] = []; 21 | 22 | promises = computePlanKeys.map((computePlanKey) => 23 | retrieveComputePlan(computePlanKey, { 24 | signal, 25 | }) 26 | ); 27 | 28 | let responses; 29 | 30 | try { 31 | responses = await Promise.all(promises); 32 | const results = responses.map((response) => response.data); 33 | set({ 34 | computePlans: results, 35 | fetchingComputePlans: false, 36 | }); 37 | } catch (error) { 38 | if (axios.isCancel(error)) { 39 | // do nothing, the call has been canceled voluntarily 40 | } else { 41 | console.warn(error); 42 | set({ fetchingComputePlans: false }); 43 | } 44 | } 45 | }), 46 | })); 47 | 48 | export default useCompareStore; 49 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/users.cy.js: -------------------------------------------------------------------------------- 1 | describe('Users page', () => { 2 | before(() => { 3 | cy.login(); 4 | }); 5 | 6 | beforeEach(() => { 7 | cy.visit('/compute_plans'); 8 | cy.getDataCy('menu-button').click(); 9 | cy.get('[data-user-role]') 10 | .invoke('data', 'user-role') 11 | .then((userRole) => { 12 | if (userRole === 'ADMIN') { 13 | cy.visit('/users'); 14 | } 15 | }); 16 | }); 17 | 18 | it('can create user', () => { 19 | cy.getDataCy('create-user').click(); 20 | cy.getDataCy('username-input').type('Test'); 21 | cy.getDataCy('password-input').type('Azertyuiop123456789$'); 22 | cy.getDataCy('submit-form').click(); 23 | 24 | cy.get('[data-name="Test"]').first().should('exist'); 25 | }); 26 | 27 | it('can update user', () => { 28 | cy.get('[data-name="Test"]') 29 | .first() 30 | .should('exist') 31 | .then(($el) => { 32 | cy.wrap($el).should('have.data', 'role', 'USER'); 33 | cy.wrap($el).click(); 34 | }); 35 | cy.get('select').eq(0).select('ADMIN'); 36 | cy.getDataCy('submit-form').click(); 37 | 38 | cy.get('[data-name="Test"]') 39 | .first() 40 | .should(($el) => { 41 | expect($el).to.have.data('role', 'ADMIN'); 42 | }); 43 | }); 44 | 45 | it('can delete user', () => { 46 | cy.get('[data-name="Test"]').first().click(); 47 | cy.getDataCy('delete-user').click(); 48 | cy.getDataCy('confirm-delete').click(); 49 | cy.get('[data-name="Test"]').should('not.exist'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/components/Timing.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from '@chakra-ui/react'; 2 | 3 | import { shortFormatDate } from '@/libs/utils'; 4 | import { ComputePlanStatus, ComputePlanT } from '@/types/ComputePlansTypes'; 5 | import { TaskT, TaskStatus } from '@/types/TasksTypes'; 6 | 7 | type TimingProps = { 8 | asset: ComputePlanT | TaskT; 9 | }; 10 | 11 | const Timing = ({ asset }: TimingProps): JSX.Element => { 12 | if (!asset.start_date) { 13 | return ( 14 | 15 | {[ 16 | ComputePlanStatus.created, 17 | TaskStatus.waitingBuilderSlot, 18 | ].includes(asset.status) 19 | ? 'Not started yet' 20 | : 'Information not available'} 21 | 22 | ); 23 | } 24 | 25 | return ( 26 | 27 | {`${shortFormatDate(asset.start_date)} -> `} 28 | {asset.end_date && ( 29 | {shortFormatDate(asset.end_date)} 30 | )} 31 | {!asset.end_date && ( 32 | 33 | {[ 34 | ComputePlanStatus.done, 35 | ComputePlanStatus.canceled, 36 | ComputePlanStatus.failed, 37 | TaskStatus.done, 38 | TaskStatus.canceled, 39 | TaskStatus.failed, 40 | ].includes(asset.status) 41 | ? 'Information not available' 42 | : 'Not ended yet'} 43 | 44 | )} 45 | 46 | ); 47 | }; 48 | export default Timing; 49 | -------------------------------------------------------------------------------- /src/routes/computePlanDetails/components/TasksBreadCrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { BreadcrumbItem, HStack, Text } from '@chakra-ui/react'; 2 | import { RiStackshareLine } from 'react-icons/ri'; 3 | 4 | import { PATHS } from '@/paths'; 5 | 6 | import Breadcrumbs from '@/components/Breadcrumbs'; 7 | import Status from '@/components/Status'; 8 | 9 | import useComputePlanStore from '../useComputePlanStore'; 10 | 11 | const ComputePlanTasksBreadcrumbs = (): JSX.Element => { 12 | const { computePlan, fetchingComputePlan } = useComputePlanStore(); 13 | 14 | return ( 15 | 20 | 21 | 22 | 28 | {fetchingComputePlan && 'Loading'} 29 | {!fetchingComputePlan && 30 | computePlan && 31 | computePlan.name} 32 | 33 | {!fetchingComputePlan && computePlan && ( 34 | 39 | )} 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default ComputePlanTasksBreadcrumbs; 47 | -------------------------------------------------------------------------------- /src/components/EmptyState.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Text, VStack } from '@chakra-ui/react'; 2 | 3 | type EmptyStateProps = { 4 | title: string; 5 | subtitle?: string; 6 | buttonOnClick?: () => void; 7 | buttonLabel?: string; 8 | icon: React.ReactNode; 9 | dataCy?: string; 10 | }; 11 | const EmptyState = ({ 12 | title, 13 | subtitle, 14 | buttonOnClick, 15 | icon, 16 | buttonLabel, 17 | dataCy, 18 | }: EmptyStateProps) => { 19 | return ( 20 | 21 | 32 | {icon} 33 | 34 | 35 | 41 | {title} 42 | 43 | {subtitle && ( 44 | 45 | {subtitle} 46 | 47 | )} 48 | 49 | {buttonOnClick && buttonLabel && ( 50 | 53 | )} 54 | 55 | ); 56 | }; 57 | export default EmptyState; 58 | -------------------------------------------------------------------------------- /src/routes/computePlans/useComputePlansStore.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { create } from 'zustand'; 3 | 4 | import { listComputePlans } from '@/api/ComputePlansApi'; 5 | import { withAbortSignal } from '@/api/request'; 6 | import { timestampNow } from '@/libs/utils'; 7 | import { APIListArgsT, AbortFunctionT } from '@/types/CommonTypes'; 8 | import { ComputePlanStubT } from '@/types/ComputePlansTypes'; 9 | 10 | type ComputePlansStateT = { 11 | computePlans: ComputePlanStubT[]; 12 | computePlansCount: number; 13 | computePlansCallTimestamp: string; 14 | fetchingComputePlans: boolean; 15 | fetchComputePlans: (params: APIListArgsT) => AbortFunctionT; 16 | }; 17 | 18 | const useComputePlansStore = create((set) => ({ 19 | computePlans: [], 20 | computePlansCount: 0, 21 | computePlansCallTimestamp: '', 22 | fetchingComputePlans: true, 23 | fetchComputePlans: withAbortSignal(async (signal, params) => { 24 | set({ fetchingComputePlans: true }); 25 | 26 | try { 27 | const response = await listComputePlans(params, { 28 | signal, 29 | }); 30 | set({ 31 | fetchingComputePlans: false, 32 | computePlans: response.data.results, 33 | computePlansCount: response.data.count, 34 | computePlansCallTimestamp: timestampNow(), 35 | }); 36 | } catch (error) { 37 | if (axios.isCancel(error)) { 38 | // do nothing, the call has been canceled voluntarily 39 | } else { 40 | console.warn(error); 41 | set({ fetchingComputePlans: false }); 42 | } 43 | } 44 | }), 45 | })); 46 | export default useComputePlansStore; 47 | -------------------------------------------------------------------------------- /charts/substra-frontend/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | 3 | image: 4 | registry: ghcr.io 5 | repository: substra/substra-frontend 6 | tag: null # default to AppVersion 7 | pullPolicy: IfNotPresent 8 | 9 | imagePullSecrets: [] 10 | nameOverride: "" 11 | fullnameOverride: "" 12 | 13 | serviceAccount: 14 | # Specifies whether a service account should be created 15 | create: true 16 | # Annotations to add to the service account 17 | annotations: {} 18 | # The name of the service account to use. 19 | # If not set and create is true, a name is generated using the fullname template 20 | name: "" 21 | 22 | podAnnotations: {} 23 | 24 | podSecurityContext: 25 | runAsNonRoot: true 26 | seccompProfile: 27 | type: RuntimeDefault 28 | fsGroup: 1000 29 | runAsUser: 1000 30 | runAsGroup: 1000 31 | 32 | securityContext: 33 | allowPrivilegeEscalation: false 34 | seccompProfile: 35 | type: RuntimeDefault 36 | capabilities: 37 | drop: 38 | - ALL 39 | readOnlyRootFilesystem: true 40 | runAsNonRoot: true 41 | runAsUser: 1000 42 | 43 | service: 44 | type: ClusterIP 45 | port: 80 46 | # nodePort: 30123 47 | 48 | ingress: 49 | enabled: false 50 | annotations: {} 51 | # kubernetes.io/ingress.class: nginx 52 | # kubernetes.io/tls-acme: "true" 53 | hosts: 54 | - host: chart-example.local 55 | paths: [] 56 | tls: [] 57 | # - secretName: chart-example-tls 58 | # hosts: 59 | # - chart-example.local 60 | 61 | resources: 62 | requests: 63 | memory: "200Mi" 64 | cpu: "100m" 65 | limits: 66 | memory: "800Mi" 67 | cpu: "100m" 68 | 69 | 70 | nodeSelector: {} 71 | 72 | tolerations: [] 73 | 74 | affinity: {} 75 | 76 | api: 77 | url: "http://substra-backend.local:8000" 78 | 79 | microsoftClarity: 80 | id: "" 81 | -------------------------------------------------------------------------------- /src/components/Breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styled from '@emotion/styled'; 4 | import { Link } from 'wouter'; 5 | 6 | import { 7 | Breadcrumb, 8 | BreadcrumbItem, 9 | BreadcrumbLink, 10 | HStack, 11 | } from '@chakra-ui/react'; 12 | 13 | import IconTag, { IconTagProps } from '@/components/IconTag'; 14 | 15 | const StyledBreadcrumb = styled(Breadcrumb)` 16 | ol { 17 | display: flex; 18 | align-items: center; 19 | } 20 | `; 21 | 22 | type BreadcrumbsProps = { 23 | rootIcon: IconTagProps['icon']; 24 | rootPath: string; 25 | rootLabel: string; 26 | children: React.ReactNode; 27 | }; 28 | 29 | const Breadcrumbs = ({ 30 | rootIcon, 31 | rootPath, 32 | rootLabel, 33 | children, 34 | }: BreadcrumbsProps): JSX.Element => ( 35 | 41 | 42 | 43 | 49 | 50 | 55 | {rootLabel} 56 | 57 | 58 | 59 | 60 | {children} 61 | 62 | ); 63 | 64 | export default Breadcrumbs; 65 | -------------------------------------------------------------------------------- /src/features/tableFilters/TaskStatusTableFilter.tsx: -------------------------------------------------------------------------------- 1 | import { useTableFilterCallbackRefs } from '@/features/tableFilters/useTableFilters'; 2 | import useSelection from '@/hooks/useSelection'; 3 | import { useStatus } from '@/hooks/useSyncedState'; 4 | import { getStatusLabel, getStatusDescription } from '@/libs/status'; 5 | import { TaskStatus } from '@/types/TasksTypes'; 6 | 7 | import TableFilterCheckboxes from './TableFilterCheckboxes'; 8 | 9 | const TaskStatusTableFilter = (): JSX.Element => { 10 | const [tmpStatus, onTmpStatusChange, resetTmpStatus, setTmpStatus] = 11 | useSelection(); 12 | 13 | const [activeStatus] = useStatus(); 14 | const { clearRef, applyRef, resetRef } = 15 | useTableFilterCallbackRefs('status'); 16 | 17 | clearRef.current = (urlSearchParams) => { 18 | resetTmpStatus(); 19 | urlSearchParams.delete('status'); 20 | }; 21 | 22 | applyRef.current = (urlSearchParams) => { 23 | if (tmpStatus.length > 0) { 24 | urlSearchParams.set('status', tmpStatus.join(',')); 25 | } else { 26 | urlSearchParams.delete('status'); 27 | } 28 | }; 29 | 30 | resetRef.current = () => { 31 | setTmpStatus(activeStatus); 32 | }; 33 | 34 | const options = Object.values(TaskStatus).map((status) => ({ 35 | value: status, 36 | label: getStatusLabel(status), 37 | description: getStatusDescription(status), 38 | })); 39 | 40 | return ( 41 | 46 | ); 47 | }; 48 | 49 | TaskStatusTableFilter.filterTitle = 'Status'; 50 | TaskStatusTableFilter.filterField = 'status'; 51 | 52 | export default TaskStatusTableFilter; 53 | -------------------------------------------------------------------------------- /src/routes/users/components/UsernameInput.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { FormControl, Input, FormErrorMessage } from '@chakra-ui/react'; 4 | 5 | import { DrawerSectionEntry } from '@/components/DrawerSection'; 6 | 7 | type UsernameInputProps = { 8 | value: string; 9 | onChange: (value: string) => void; 10 | hasErrors: boolean; 11 | setHasErrors: (hasErrors: boolean) => void; 12 | isDisabled?: boolean; 13 | }; 14 | 15 | const UsernameInput = ({ 16 | value, 17 | onChange, 18 | hasErrors, 19 | setHasErrors, 20 | isDisabled, 21 | }: UsernameInputProps): JSX.Element => { 22 | const [isDirty, setIsDirty] = useState(false); 23 | 24 | return ( 25 | 26 | 30 | { 38 | onChange(e.target.value); 39 | setIsDirty(true); 40 | setHasErrors(e.target.value === ''); 41 | }} 42 | data-cy="username-input" 43 | /> 44 | {hasErrors && isDirty && ( 45 | 46 | You must enter a username 47 | 48 | )} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default UsernameInput; 55 | -------------------------------------------------------------------------------- /src/components/PermissionTag.tsx: -------------------------------------------------------------------------------- 1 | import { Tag, TagLabel, TagRightIcon, Wrap, WrapItem } from '@chakra-ui/react'; 2 | import { 3 | RiGlobalLine, 4 | RiGroupLine, 5 | RiLockLine, 6 | RiUserLine, 7 | } from 'react-icons/ri'; 8 | 9 | import { PermissionT } from '@/types/CommonTypes'; 10 | 11 | type PermissionTagProps = { 12 | permission: PermissionT; 13 | listOrganizations?: boolean; 14 | }; 15 | const PermissionTag = ({ 16 | permission, 17 | listOrganizations, 18 | }: PermissionTagProps): JSX.Element => { 19 | if (permission.public) { 20 | return ( 21 | 22 | Everybody 23 | 24 | 25 | ); 26 | } 27 | if (permission.authorized_ids.length === 1) { 28 | return ( 29 | 30 | Owner only 31 | 32 | 33 | ); 34 | } 35 | 36 | if (listOrganizations) { 37 | return ( 38 | 39 | {permission.authorized_ids.map((nodeId) => ( 40 | 41 | 42 | {nodeId} 43 | 44 | 45 | 46 | ))} 47 | 48 | ); 49 | } else { 50 | return ( 51 | 52 | Restricted 53 | 54 | 55 | ); 56 | } 57 | }; 58 | export default PermissionTag; 59 | -------------------------------------------------------------------------------- /src/routes/computePlans/components/StatusCell.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | Popover, 4 | PopoverBody, 5 | PopoverContent, 6 | PopoverTrigger, 7 | Text, 8 | VStack, 9 | } from '@chakra-ui/react'; 10 | 11 | import { ComputePlanT } from '@/types/ComputePlansTypes'; 12 | 13 | import ComputePlanProgressBar from '@/components/ComputePlanProgressBar'; 14 | import ComputePlanTaskStatuses from '@/components/ComputePlanTaskStatuses'; 15 | import Status from '@/components/Status'; 16 | 17 | type StatusCellProps = { 18 | computePlan: ComputePlanT; 19 | }; 20 | const StatusCell = ({ computePlan }: StatusCellProps): JSX.Element => { 21 | return ( 22 | 23 | 24 | 25 | 30 | 31 | 32 | {computePlan.done_count + computePlan.failed_count}/ 33 | {computePlan.task_count} 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | }; 47 | 48 | export default StatusCell; 49 | -------------------------------------------------------------------------------- /src/features/tableFilters/ComputePlanStatusTableFilter.tsx: -------------------------------------------------------------------------------- 1 | import { useTableFilterCallbackRefs } from '@/features/tableFilters/useTableFilters'; 2 | import useSelection from '@/hooks/useSelection'; 3 | import { useStatus } from '@/hooks/useSyncedState'; 4 | import { getStatusLabel, getStatusDescription } from '@/libs/status'; 5 | import { ComputePlanStatus } from '@/types/ComputePlansTypes'; 6 | 7 | import TableFilterCheckboxes from './TableFilterCheckboxes'; 8 | 9 | const ComputePlanStatusTableFilter = (): JSX.Element => { 10 | const [tmpStatus, onTmpStatusChange, resetTmpStatus, setTmpStatus] = 11 | useSelection(); 12 | 13 | const [activeStatus] = useStatus(); 14 | const { clearRef, applyRef, resetRef } = 15 | useTableFilterCallbackRefs('status'); 16 | 17 | clearRef.current = (urlSearchParams) => { 18 | resetTmpStatus(); 19 | urlSearchParams.delete('status'); 20 | }; 21 | 22 | applyRef.current = (urlSearchParams) => { 23 | if (tmpStatus.length > 0) { 24 | urlSearchParams.set('status', tmpStatus.join(',')); 25 | } else { 26 | urlSearchParams.delete('status'); 27 | } 28 | }; 29 | 30 | resetRef.current = () => { 31 | setTmpStatus(activeStatus); 32 | }; 33 | 34 | const options = Object.values(ComputePlanStatus).map((status) => ({ 35 | value: status, 36 | label: getStatusLabel(status), 37 | description: getStatusDescription(status), 38 | })); 39 | 40 | return ( 41 | 46 | ); 47 | }; 48 | 49 | ComputePlanStatusTableFilter.filterTitle = 'Status'; 50 | ComputePlanStatusTableFilter.filterField = 'status'; 51 | 52 | export default ComputePlanStatusTableFilter; 53 | -------------------------------------------------------------------------------- /src/components/DrawerHeader.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Skeleton, 3 | DrawerHeader as ChakraDrawerHeader, 4 | Heading, 5 | IconButton, 6 | HStack, 7 | } from '@chakra-ui/react'; 8 | import { RiDownloadLine } from 'react-icons/ri'; 9 | 10 | type DrawerHeaderProps = { 11 | loading: boolean; 12 | title?: string; 13 | onClose: () => void; 14 | extraButtons?: React.ReactNode; 15 | updateNameButton?: React.ReactNode; 16 | updateNameDialog?: React.ReactNode; 17 | }; 18 | 19 | const DrawerHeader = ({ 20 | loading, 21 | title, 22 | onClose, 23 | extraButtons, 24 | updateNameButton, 25 | updateNameDialog, 26 | }: DrawerHeaderProps): JSX.Element => ( 27 | 35 | {loading && } 36 | {!loading && ( 37 | 44 | {title} 45 | 46 | )} 47 | 48 | {extraButtons} 49 | {updateNameButton} 50 | {updateNameDialog} 51 | } 58 | onClick={onClose} 59 | /> 60 | 61 | 62 | ); 63 | 64 | export default DrawerHeader; 65 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/computePlans.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Compute plans page', () => { 4 | before(() => { 5 | cy.login(); 6 | }); 7 | 8 | beforeEach(() => { 9 | cy.visit('/compute_plans'); 10 | }); 11 | 12 | it('lists compute plans', () => { 13 | cy.get('tbody[data-cy=loaded]') 14 | .get('tr') 15 | .should('have.length.greaterThan', 1); 16 | }); 17 | 18 | it('displays tasks count bar in status/task column', () => { 19 | cy.get('tbody[data-cy=loaded]') 20 | .get('tr') 21 | .eq(1) 22 | .within(() => { 23 | cy.getDataCy('cp-tasks-status') 24 | .should('exist') 25 | .trigger('mouseover'); 26 | cy.getDataCy('cp-tasks-status-tooltip').should('be.visible'); 27 | }); 28 | }); 29 | 30 | it('searches CP with a key', () => { 31 | cy.checkSearchByKey('compute_plans'); 32 | }); 33 | 34 | it('adds a cp to favorites', () => { 35 | cy.getDataCy('favorite-cp').should('not.exist'); 36 | cy.getDataCy('favorite-box').first().click(); 37 | cy.getDataCy('favorite-cp').should('exist'); 38 | }); 39 | 40 | it('selects/unselects cp in list', () => { 41 | cy.getDataCy('selection-popover').should('not.exist'); 42 | cy.get('[data-cy="selection-box"]>input') 43 | .first() 44 | .check({ force: true }); 45 | cy.getDataCy('selection-popover').should('exist'); 46 | cy.get('[data-cy="selection-box"]>input') 47 | .first() 48 | .uncheck({ force: true }); 49 | cy.getDataCy('selection-popover').should('not.exist'); 50 | }); 51 | 52 | it('opens filters', () => { 53 | cy.checkOpenFilters(1); 54 | }); 55 | 56 | it('can filter cps by status', () => { 57 | cy.checkFilterAssetsBy('status'); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/components/ComputePlanProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, HStack } from '@chakra-ui/react'; 2 | 3 | import { getStatusStyle } from '@/libs/status'; 4 | import { getStatusCount } from '@/routes/computePlanDetails/ComputePlanUtils'; 5 | import { ComputePlanT } from '@/types/ComputePlansTypes'; 6 | import { TaskStatus, taskStatusOrder } from '@/types/TasksTypes'; 7 | 8 | type ItemProps = { 9 | status: TaskStatus; 10 | count: number; 11 | total: number; 12 | }; 13 | const Item = ({ status, count, total }: ItemProps): JSX.Element | null => { 14 | if (!count) { 15 | return null; 16 | } 17 | 18 | const percentage = Math.round((count / total) * 100); 19 | 20 | return ( 21 | 26 | ); 27 | }; 28 | 29 | type ComputePlanProgressBarProps = { 30 | computePlan: ComputePlanT; 31 | }; 32 | 33 | const ComputePlanProgressBar = ({ 34 | computePlan, 35 | }: ComputePlanProgressBarProps): JSX.Element => { 36 | return ( 37 | 44 | {!computePlan.task_count && ( 45 | 50 | )} 51 | {taskStatusOrder.map((status) => ( 52 | 58 | ))} 59 | 60 | ); 61 | }; 62 | 63 | export default ComputePlanProgressBar; 64 | -------------------------------------------------------------------------------- /src/routes/tasks/components/TaskDurationBar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { TaskExecutionRundownT } from '@/types/ProfilingTypes'; 4 | 5 | import ProfilingDurationBar from '@/components/ProfilingDurationBar'; 6 | 7 | import { taskStepsInfo } from '../TasksUtils'; 8 | import useTaskStore from '../useTaskStore'; 9 | 10 | const PROFILING_BAR_TOOLTIP = 11 | "This is an experimental feature. The sum of task's steps durations might not be equal to the task duration."; 12 | 13 | // Returns sum duration of all step currently done in seconds 14 | // Returns null if no step is done 15 | const getTaskDuration = ( 16 | taskProfiling: TaskExecutionRundownT | null 17 | ): number | null => { 18 | if (!taskProfiling || taskProfiling.execution_rundown.length === 0) { 19 | return null; 20 | } 21 | 22 | return taskProfiling.execution_rundown.reduce( 23 | (taskDuration, step) => taskDuration + step.duration, 24 | 0 25 | ); 26 | }; 27 | 28 | const TaskDurationBar = ({ 29 | taskKey, 30 | }: { 31 | taskKey: string | null | undefined; 32 | }): JSX.Element => { 33 | const { taskProfiling, fetchingTaskProfiling, fetchTaskProfiling } = 34 | useTaskStore(); 35 | 36 | useEffect(() => { 37 | if (taskKey) { 38 | fetchTaskProfiling(taskKey); 39 | } 40 | }, [fetchTaskProfiling, taskKey]); 41 | const [taskDuration, setTaskDuration] = useState(null); 42 | 43 | useEffect(() => { 44 | setTaskDuration(getTaskDuration(taskProfiling)); 45 | }, [taskProfiling]); 46 | 47 | return ( 48 | 56 | ); 57 | }; 58 | 59 | export default TaskDurationBar; 60 | -------------------------------------------------------------------------------- /src/routes/dataset/components/DetailsSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { VStack } from '@chakra-ui/react'; 2 | 3 | import { 4 | DrawerSection, 5 | DrawerSectionDateEntry, 6 | DrawerSectionKeyEntry, 7 | OrganizationDrawerSectionEntry, 8 | PermissionsDrawerSectionEntry, 9 | } from '@/components/DrawerSection'; 10 | import MetadataDrawerSection from '@/components/MetadataDrawerSection'; 11 | 12 | import useDatasetStore from '../useDatasetStore'; 13 | import DataSamplesDrawerSection from './DataSamplesDrawerSection'; 14 | 15 | const DetailsSidebar = (): JSX.Element => { 16 | const { dataset } = useDatasetStore(); 17 | return ( 18 | 19 | 20 | {dataset && ( 21 | <> 22 | 23 | 27 | 31 | 34 | 38 | 39 | )} 40 | 41 | {dataset && } 42 | {dataset && ( 43 | 44 | )} 45 | 46 | ); 47 | }; 48 | 49 | export default DetailsSidebar; 50 | -------------------------------------------------------------------------------- /src/features/perfBrowser/usePerfBrowserColors.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext } from 'react'; 2 | 3 | import chakraTheme from '@/assets/chakraTheme'; 4 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser'; 5 | 6 | type ColorDiscriminantProps = { 7 | computePlanKey: string; 8 | worker: string; 9 | }; 10 | 11 | export const PERF_BROWSER_COLORSCHEMES = [ 12 | 'primary', 13 | 'orange', 14 | 'green', 15 | 'pink', 16 | 'yellow', 17 | 'cyan', 18 | 'red', 19 | 'purple', 20 | 'blue', 21 | ]; 22 | 23 | const usePerfBrowserColors = () => { 24 | const { colorMode, computePlans, organizations } = 25 | useContext(PerfBrowserContext); 26 | 27 | const getColorScheme = useCallback( 28 | ({ computePlanKey, worker }: ColorDiscriminantProps): string => { 29 | let index = 0; 30 | if (colorMode === 'computePlan') { 31 | index = computePlans.findIndex( 32 | (computePlan) => computePlan.key === computePlanKey 33 | ); 34 | } else { 35 | index = organizations.findIndex( 36 | (organization) => organization.id === worker 37 | ); 38 | } 39 | 40 | if (index === -1) { 41 | return 'primary'; 42 | } 43 | return PERF_BROWSER_COLORSCHEMES[ 44 | index % PERF_BROWSER_COLORSCHEMES.length 45 | ]; 46 | }, 47 | [colorMode, computePlans, organizations] 48 | ); 49 | 50 | const getColor = useCallback( 51 | ( 52 | colorDiscriminant: ColorDiscriminantProps, 53 | intensity: string 54 | ): string => { 55 | return chakraTheme.colors[getColorScheme(colorDiscriminant)][ 56 | intensity 57 | ]; 58 | }, 59 | [getColorScheme] 60 | ); 61 | 62 | return { 63 | getColorScheme, 64 | getColor, 65 | }; 66 | }; 67 | 68 | export default usePerfBrowserColors; 69 | -------------------------------------------------------------------------------- /charts/substra-frontend-tests/values.yaml: -------------------------------------------------------------------------------- 1 | replicaCount: 1 2 | 3 | image: 4 | registry: ghcr.io 5 | repository: substra/substra-frontend 6 | tag: null # defaults to AppVersion 7 | pullPolicy: IfNotPresent 8 | 9 | imagePullSecrets: [] 10 | nameOverride: "" 11 | fullnameOverride: "" 12 | 13 | serviceAccount: 14 | # Specifies whether a service account should be created 15 | create: true 16 | # Annotations to add to the service account 17 | annotations: {} 18 | # The name of the service account to use. 19 | # If not set and create is true, a name is generated using the fullname template 20 | name: "" 21 | 22 | podAnnotations: {} 23 | 24 | podSecurityContext: {} 25 | # fsGroup: 2000 26 | 27 | securityContext: {} 28 | # capabilities: 29 | # drop: 30 | # - ALL 31 | # readOnlyRootFilesystem: true 32 | # runAsNonRoot: true 33 | # runAsUser: 1000 34 | 35 | service: 36 | type: ClusterIP 37 | port: 80 38 | 39 | ingress: 40 | enabled: false 41 | annotations: {} 42 | # kubernetes.io/ingress.class: nginx 43 | # kubernetes.io/tls-acme: "true" 44 | hosts: 45 | - host: chart-example.local 46 | paths: [] 47 | tls: [] 48 | # - secretName: chart-example-tls 49 | # hosts: 50 | # - chart-example.local 51 | 52 | resources: {} 53 | # We usually recommend not to specify default resources and to leave this as a conscious 54 | # choice for the user. This also increases chances charts run on environments with little 55 | # resources, such as Minikube. If you do want to specify resources, uncomment the following 56 | # lines, adjust them as necessary, and remove the curly braces after 'resources:'. 57 | # limits: 58 | # cpu: 100m 59 | # memory: 128Mi 60 | # requests: 61 | # cpu: 100m 62 | # memory: 128Mi 63 | 64 | nodeSelector: {} 65 | 66 | tolerations: [] 67 | 68 | affinity: {} 69 | 70 | 71 | cypress: 72 | config: 73 | baseUrl: "http://frontend" 74 | env: 75 | USERNAME: "org-1" 76 | PASSWORD: "p@sswr0d44" 77 | BACKEND_API_URL: "http://backend" 78 | video: false 79 | defaultCommandTimeout: 20000 80 | 81 | screenshotsPvc: 82 | enabled: false 83 | retrieverEnabled: false 84 | -------------------------------------------------------------------------------- /charts/substra-frontend/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "substra-frontend.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "substra-frontend.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "substra-frontend.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "substra-frontend.labels" -}} 37 | helm.sh/chart: {{ include "substra-frontend.chart" . }} 38 | {{ include "substra-frontend.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | default .Chart.Version | replace "+" "_" | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "substra-frontend.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "substra-frontend.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "substra-frontend.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "substra-frontend.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /src/routes/tokens/components/NewTokenAlert.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Alert, 3 | AlertIcon, 4 | Box, 5 | AlertTitle, 6 | AlertDescription, 7 | HStack, 8 | Text, 9 | AlertProps, 10 | } from '@chakra-ui/react'; 11 | import { RiInformationLine } from 'react-icons/ri'; 12 | 13 | import CopyIconButton from '@/features/copy/CopyIconButton'; 14 | 15 | type NewTokenAlertProps = AlertProps & { 16 | tokenKey: string; 17 | }; 18 | 19 | const NewTokenAlert = ({ 20 | tokenKey, 21 | ...props 22 | }: NewTokenAlertProps): JSX.Element => { 23 | return ( 24 | 32 | 33 | 34 | 35 | 36 | Your token has been generated! 37 | 38 | 39 | Make sure to copy your personal access token now. You won’t 40 | be able to see it again! 41 | 42 | 49 | 50 | 51 | {tokenKey} 52 | 53 | 54 | 55 | 60 | 61 | 62 | 63 | ); 64 | }; 65 | 66 | export default NewTokenAlert; 67 | -------------------------------------------------------------------------------- /src/assets/Fonts.tsx: -------------------------------------------------------------------------------- 1 | import { Global } from '@emotion/react'; 2 | 3 | const Fonts = () => ( 4 | 58 | ); 59 | 60 | export default Fonts; 61 | -------------------------------------------------------------------------------- /charts/substra-frontend-tests/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "substra-frontend-tests.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "substra-frontend-tests.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Create chart name and version as used by the chart label. 28 | */}} 29 | {{- define "substra-frontend-tests.chart" -}} 30 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 31 | {{- end }} 32 | 33 | {{/* 34 | Common labels 35 | */}} 36 | {{- define "substra-frontend-tests.labels" -}} 37 | helm.sh/chart: {{ include "substra-frontend-tests.chart" . }} 38 | {{ include "substra-frontend-tests.selectorLabels" . }} 39 | {{- if .Chart.AppVersion }} 40 | app.kubernetes.io/version: {{ .Chart.AppVersion | default .Chart.Version | replace "+" "_" | quote }} 41 | {{- end }} 42 | app.kubernetes.io/managed-by: {{ .Release.Service }} 43 | {{- end }} 44 | 45 | {{/* 46 | Selector labels 47 | */}} 48 | {{- define "substra-frontend-tests.selectorLabels" -}} 49 | app.kubernetes.io/name: {{ include "substra-frontend-tests.name" . }} 50 | app.kubernetes.io/instance: {{ .Release.Name }} 51 | {{- end }} 52 | 53 | {{/* 54 | Create the name of the service account to use 55 | */}} 56 | {{- define "substra-frontend-tests.serviceAccountName" -}} 57 | {{- if .Values.serviceAccount.create }} 58 | {{- default (include "substra-frontend-tests.fullname" .) .Values.serviceAccount.name }} 59 | {{- else }} 60 | {{- default "default" .Values.serviceAccount.name }} 61 | {{- end }} 62 | {{- end }} 63 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfSidebarSettingsUnits.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Box, Flex, Heading, Text, Select } from '@chakra-ui/react'; 4 | 5 | import { 6 | PerfBrowserContext, 7 | XAxisModeT, 8 | YAxisModeT, 9 | } from '@/features/perfBrowser/usePerfBrowser'; 10 | 11 | const PerfSidebarSettingsUnits = (): JSX.Element => { 12 | const { 13 | xAxisMode, 14 | setXAxisMode, 15 | yAxisMode, 16 | setYAxisMode, 17 | seriesGroupsWithRounds, 18 | } = useContext(PerfBrowserContext); 19 | 20 | return ( 21 | 22 | 23 | Parameters 24 | 25 | 26 | X axis 27 | 43 | 44 | 45 | Y axis 46 | 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default PerfSidebarSettingsUnits; 63 | -------------------------------------------------------------------------------- /src/features/tableFilters/ComputePlanFavoritesTableFilter.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import { Box, Checkbox, Text } from '@chakra-ui/react'; 4 | 5 | import { useTableFilterCallbackRefs } from '@/features/tableFilters/useTableFilters'; 6 | import { useFavoritesOnly } from '@/hooks/useSyncedState'; 7 | 8 | const ComputePlanFavoritesTableFilter = ({ 9 | favorites, 10 | }: { 11 | favorites: string[]; 12 | }): JSX.Element => { 13 | const [tmpFavoritesOnly, setTmpFavoritesOnly] = useState(false); 14 | 15 | const [activeFavoritesOnly] = useFavoritesOnly(); 16 | const { clearRef, applyRef, resetRef } = 17 | useTableFilterCallbackRefs('favorites'); 18 | 19 | clearRef.current = (urlSearchParams) => { 20 | urlSearchParams.delete('favorites_only'); 21 | }; 22 | 23 | applyRef.current = (urlSearchParams) => { 24 | if (tmpFavoritesOnly) { 25 | urlSearchParams.set('favorites_only', '1'); 26 | } else { 27 | urlSearchParams.delete('favorites_only'); 28 | } 29 | }; 30 | 31 | resetRef.current = () => { 32 | setTmpFavoritesOnly(activeFavoritesOnly); 33 | }; 34 | 35 | const onChange = () => { 36 | setTmpFavoritesOnly(!tmpFavoritesOnly); 37 | }; 38 | 39 | return ( 40 | 41 | 42 | Filter by 43 | 44 | 50 | 51 | Favorites Only 52 | 53 | 54 | {!favorites.length && ( 55 | 56 | You currently have no favorite compute plan 57 | 58 | )} 59 | 60 | ); 61 | }; 62 | 63 | ComputePlanFavoritesTableFilter.filterTitle = 'Favorites'; 64 | ComputePlanFavoritesTableFilter.filterField = 'favorites_only'; 65 | 66 | export default ComputePlanFavoritesTableFilter; 67 | -------------------------------------------------------------------------------- /src/routes/dataset/components/DataSamplesDrawerSection.tsx: -------------------------------------------------------------------------------- 1 | import { HStack, Text } from '@chakra-ui/react'; 2 | import { RiDatabase2Fill } from 'react-icons/ri'; 3 | 4 | import CopyIconButton from '@/features/copy/CopyIconButton'; 5 | 6 | import DownloadIconButton from '@/components/DownloadIconButton'; 7 | import { 8 | DrawerSection, 9 | DrawerSectionEntryWrapper, 10 | } from '@/components/DrawerSection'; 11 | import IconTag from '@/components/IconTag'; 12 | 13 | type DataSamplesDrawerSectionProps = { 14 | keys: string[]; 15 | }; 16 | const DataSamplesDrawerSection = ({ 17 | keys, 18 | }: DataSamplesDrawerSectionProps): JSX.Element => { 19 | const keysAsJson = JSON.stringify(keys); 20 | const keysAsBlob = new Blob([keysAsJson], { type: 'application/json' }); 21 | return ( 22 | 23 | 28 | 29 | 34 | {`${keys.length} data samples`} 35 | 36 | {keys.length > 0 && ( 37 | 38 | 44 | 50 | 51 | )} 52 | 53 | 54 | ); 55 | }; 56 | export default DataSamplesDrawerSection; 57 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfDetails.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { Button, Flex, Kbd } from '@chakra-ui/react'; 4 | import { RiArrowLeftLine } from 'react-icons/ri'; 5 | 6 | import PerfChart from '@/features/perfBrowser/PerfChart'; 7 | import PerfEmptyState from '@/features/perfBrowser/PerfEmptyState'; 8 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser'; 9 | import { useKeyPress } from '@/hooks/useKeyPress'; 10 | import { SerieT } from '@/types/SeriesTypes'; 11 | 12 | type PerfDetailsProps = { 13 | series: SerieT[]; 14 | }; 15 | 16 | const PerfDetails = ({ series }: PerfDetailsProps): JSX.Element => { 17 | const { perfChartRef, setSelectedIdentifier } = 18 | useContext(PerfBrowserContext); 19 | 20 | const resetSelectedMetric = () => { 21 | setSelectedIdentifier(''); 22 | }; 23 | 24 | useKeyPress('Escape', () => resetSelectedMetric()); 25 | 26 | return ( 27 | 37 | 0 ? [series] : []} /> 38 | {series.length > 0 && ( 39 | <> 40 | 45 | 58 | 59 | )} 60 | 61 | ); 62 | }; 63 | 64 | export default PerfDetails; 65 | -------------------------------------------------------------------------------- /src/routes/compare/components/CompareBreadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { BreadcrumbItem, HStack, Text, Link } from '@chakra-ui/react'; 4 | import { RiStackshareLine } from 'react-icons/ri'; 5 | 6 | import { PerfBrowserContext } from '@/features/perfBrowser/usePerfBrowser'; 7 | import { PATHS } from '@/paths'; 8 | 9 | import Breadcrumbs from '@/components/Breadcrumbs'; 10 | 11 | const CompareBreadcrumbs = (): JSX.Element => { 12 | const { selectedIdentifier, setSelectedIdentifier } = 13 | useContext(PerfBrowserContext); 14 | 15 | return ( 16 | 21 | 22 | 23 | {selectedIdentifier ? ( 24 | setSelectedIdentifier('')} 30 | > 31 | Comparison 32 | 33 | ) : ( 34 | 40 | Comparison 41 | 42 | )} 43 | 44 | 45 | {selectedIdentifier && ( 46 | 47 | 53 | {selectedIdentifier} 54 | 55 | 56 | )} 57 | 58 | ); 59 | }; 60 | 61 | export default CompareBreadcrumbs; 62 | -------------------------------------------------------------------------------- /src/features/tableFilters/TableFilterCheckboxes.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Checkbox, VStack, Text } from '@chakra-ui/react'; 2 | 3 | type OptionT = { value: string; label: string; description?: string } | string; 4 | 5 | export const getOptionValue = (option: OptionT) => 6 | typeof option === 'string' ? option : option.value; 7 | 8 | export const getOptionLabel = (option: OptionT) => 9 | typeof option === 'string' ? option : option.label; 10 | 11 | export const getOptionDescription = (option: OptionT) => 12 | typeof option === 'string' ? null : option.description; 13 | 14 | type TableFilterCheckboxesProps = { 15 | title?: string; 16 | value: string[]; 17 | options: OptionT[]; 18 | onChange: ( 19 | value: string 20 | ) => (event: React.ChangeEvent) => void; 21 | }; 22 | 23 | const TableFilterCheckboxes = ({ 24 | title, 25 | value, 26 | onChange, 27 | options, 28 | }: TableFilterCheckboxesProps): JSX.Element => { 29 | return ( 30 | 31 | 32 | {title ?? 'Filter by'} 33 | 34 | 35 | {options.map((option) => ( 36 | 45 | 46 | {getOptionLabel(option)} 47 | 48 | {getOptionDescription(option) && ( 49 | 50 | {getOptionDescription(option)} 51 | 52 | )} 53 | 54 | ))} 55 | 56 | 57 | ); 58 | }; 59 | 60 | export default TableFilterCheckboxes; 61 | -------------------------------------------------------------------------------- /src/hooks/useLocationWithParams.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from 'wouter'; 2 | 3 | export const getUrlSearchParams = (): URLSearchParams => 4 | new URLSearchParams(window.location.search); 5 | 6 | type SetLocationWithParamsProps = ( 7 | to: string, 8 | params: URLSearchParams, 9 | options?: { replace?: boolean } 10 | ) => void; 11 | 12 | const useLocationWithParams = (): [string, SetLocationWithParamsProps] => { 13 | const [location, setLocation] = useLocation(); 14 | const setLocationWithParams: SetLocationWithParamsProps = ( 15 | to, 16 | params, 17 | options 18 | ) => { 19 | setLocation(`${to}?${params.toString()}`, options); 20 | }; 21 | return [location, setLocationWithParams]; 22 | }; 23 | 24 | export const useSetLocationParams = (): (( 25 | urlSearchParams: URLSearchParams 26 | ) => void) => { 27 | const [, setLocationWithParams] = useLocationWithParams(); 28 | 29 | const setLocationParams = (urlSearchParams: URLSearchParams) => { 30 | setLocationWithParams(window.location.pathname, urlSearchParams, { 31 | replace: true, 32 | }); 33 | }; 34 | 35 | return setLocationParams; 36 | }; 37 | 38 | export const useSetLocationPreserveParams = () => { 39 | const [, setLocationWithParams] = useLocationWithParams(); 40 | 41 | const setLocationPreserveParams = (to: string): void => { 42 | setLocationWithParams(to, getUrlSearchParams()); 43 | }; 44 | 45 | return setLocationPreserveParams; 46 | }; 47 | 48 | export const useHrefLocation = (): [string, (path: string) => void] => { 49 | const [location, setLocation] = useLocation(); 50 | const setLocationParams = useSetLocationParams(); 51 | 52 | const setHrefLocation = (path: string) => { 53 | if (path === location) { 54 | const urlSearchParams = getUrlSearchParams(); 55 | const newUrlSearchParams = new URLSearchParams(); 56 | newUrlSearchParams.set('page', '1'); 57 | const ordering = urlSearchParams.get('ordering'); 58 | if (ordering) { 59 | newUrlSearchParams.set('ordering', ordering); 60 | } 61 | setLocationParams(newUrlSearchParams); 62 | } else { 63 | setLocation(path); 64 | } 65 | }; 66 | 67 | return [location, setHrefLocation]; 68 | }; 69 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | --------------------------------------------------------------------------------
29 | ); 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Ask a question 4 | url: https://join.slack.com/t/substra-workspace/shared_invite/zt-1fqnk0nw6-xoPwuLJ8dAPXThfyldX8yA 5 | about: Don't hesitate to join the Substra community on Slack to ask all your questions! 6 | - name: User Documentation Improvement 7 | url: https://github.com/Substra/substra-documentation/issues 8 | about: For issues related to the User Documentation, please open an issue on the substra-documentation repository 9 | - name: Feature request 10 | url: https://github.com/Substra/substra/issues 11 | about: We centralize feature requests in the substra repository, please open an issue there 12 | -------------------------------------------------------------------------------- /src/types/PerformancesTypes.ts: -------------------------------------------------------------------------------- 1 | export type PerformanceT = { 2 | compute_task: { 3 | key: string; 4 | function_key: string; 5 | rank: number; 6 | round_idx: string | null; 7 | worker: string; 8 | }; 9 | metric: { 10 | key: string; 11 | name: string; 12 | }; 13 | identifier: string; 14 | perf: number | null; 15 | }; 16 | 17 | export type PerformanceAssetT = { 18 | channel: string; 19 | compute_task_key: string; 20 | creation_date: string; 21 | metric_key: string; 22 | performance_value: number; 23 | }; 24 | 25 | export type ComputePlanStatisticsT = { 26 | compute_tasks_distinct_ranks: number[]; 27 | compute_tasks_distinct_rounds: number[]; 28 | }; 29 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | clearMocks: true, 8 | setupFilesAfterEnv: ['/jest.setup.ts'], 9 | testEnvironment: 'jest-environment-jsdom', 10 | roots: ['src/'], 11 | globals: { 12 | API_URL: 'http://foo.bar', 13 | }, 14 | moduleNameMapper: { 15 | '@/(.*)$': '/src/$1', 16 | }, 17 | testMatch: [ 18 | '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', 19 | '/src/**/*.{spec,test}.{ts,tsx}', 20 | ], 21 | transform: { 22 | '^.+\\.tsx$': 'ts-jest', 23 | '^.+\\.ts$': 'ts-jest', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/routes/computePlanDetails/ComputePlanRoot.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { useLocation, useParams } from 'wouter'; 4 | 5 | import { compilePath, PATHS } from '@/paths'; 6 | import NotFound from '@/routes/notfound/NotFound'; 7 | 8 | export default () => { 9 | const [, setLocation] = useLocation(); 10 | const { key } = useParams(); 11 | 12 | useEffect(() => { 13 | if (key) { 14 | setLocation( 15 | compilePath(PATHS.COMPUTE_PLAN_TASKS, { 16 | key, 17 | }), 18 | { replace: true } 19 | ); 20 | } 21 | }, [key, setLocation]); 22 | 23 | if (!key) { 24 | return ; 25 | } 26 | 27 | return null; 28 | }; 29 | -------------------------------------------------------------------------------- /src/features/updateAsset/UpdateNameButton.tsx: -------------------------------------------------------------------------------- 1 | import { IconButton } from '@chakra-ui/react'; 2 | import { RiPencilLine } from 'react-icons/ri'; 3 | 4 | type UpdateNameButtonProps = { 5 | title: string; 6 | updating: boolean; 7 | openUpdateNameDialog: () => void; 8 | }; 9 | 10 | const UpdateNameButton = ({ 11 | title, 12 | updating, 13 | openUpdateNameDialog, 14 | }: UpdateNameButtonProps) => { 15 | return ( 16 | } 22 | isDisabled={updating} 23 | onClick={openUpdateNameDialog} 24 | /> 25 | ); 26 | }; 27 | export default UpdateNameButton; 28 | -------------------------------------------------------------------------------- /src/routes/tokens/BearerTokenUtils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BearerTokenT, 3 | NewBearerTokenT, 4 | RawBearerTokenT, 5 | RawNewBearerTokenT, 6 | } from '@/types/BearerTokenTypes'; 7 | 8 | export const parseToken = (object: RawBearerTokenT): BearerTokenT => { 9 | return { 10 | created_at: new Date(object.created_at), 11 | expires_at: new Date(object.expires_at), 12 | note: object.note, 13 | id: object.id, 14 | }; 15 | }; 16 | 17 | export const parseNewToken = (object: RawNewBearerTokenT): NewBearerTokenT => { 18 | return { 19 | token: object.token, 20 | created_at: new Date(object.created_at), 21 | expires_at: new Date(object.expires_at), 22 | note: object.note, 23 | id: object.id, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/features/tableFilters/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ComputePlanStatusTableFilter } from './ComputePlanStatusTableFilter'; 2 | export { default as ComputePlanFavoritesTableFilter } from './ComputePlanFavoritesTableFilter'; 3 | export { default as DurationTableFilter } from './DurationTableFilter'; 4 | export { default as MetadataTableFilter } from './MetadataTableFilter'; 5 | export { 6 | CreationDateTableFilter, 7 | EndDateTableFilter, 8 | StartDateTableFilter, 9 | } from './DateTableFilter'; 10 | export { 11 | LogsAccessTableFilter, 12 | OwnerTableFilter, 13 | PermissionsTableFilter, 14 | WorkerTableFilter, 15 | } from './OrganizationTableFilter'; 16 | export { default as TableFilters } from './TableFilters'; 17 | export { default as TaskStatusTableFilter } from './TaskStatusTableFilter'; 18 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/login.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Login page', () => { 4 | beforeEach(() => { 5 | cy.request('GET', `${Cypress.env('BACKEND_API_URL')}/me/logout`); 6 | }); 7 | 8 | it('redirects to /login automatically', () => { 9 | cy.visit('/'); 10 | cy.url().should('include', '/login'); 11 | }); 12 | 13 | it('displays error message for bad login/password', () => { 14 | cy.visit('/login'); 15 | cy.get('input[type=text]').type('foo'); 16 | cy.get('input[type=password').type('bar'); 17 | cy.get('button[type=submit]').click(); 18 | cy.contains('No active account found with the given credentials'); 19 | }); 20 | 21 | it('has cookies when login is successful', () => { 22 | cy.login(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/api/ProfilingApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import { API_PATHS, compilePath } from '@/paths'; 4 | import { FunctionProfilingT } from '@/types/FunctionsTypes'; 5 | import { TaskProfilingT } from '@/types/TasksTypes'; 6 | 7 | import API from './request'; 8 | 9 | export const retrieveTaskProfiling = ( 10 | key: string, 11 | config: AxiosRequestConfig 12 | ): AxiosPromise => 13 | API.authenticatedGet( 14 | compilePath(API_PATHS.TASK_PROFILING, { key }), 15 | config 16 | ); 17 | 18 | export const retrieveFunctionProfiling = ( 19 | key: string, 20 | config: AxiosRequestConfig 21 | ): AxiosPromise => 22 | API.authenticatedGet( 23 | compilePath(API_PATHS.FUNCTION_PROFILING, { key }), 24 | config 25 | ); 26 | -------------------------------------------------------------------------------- /automated-e2e-tests/skaffold.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: skaffold/v2beta17 2 | kind: Config 3 | build: 4 | artifacts: 5 | - image: &imageref substra/substra-frontend-tests 6 | context: ../e2e-tests 7 | docker: 8 | dockerfile: 'docker/substra-frontend-tests/Dockerfile' 9 | 10 | deploy: 11 | helm: 12 | releases: 13 | - name: test 14 | chartPath: ../charts/substra-frontend-tests 15 | namespace: org-1 16 | createNamespace: true 17 | artifactOverrides: 18 | image: *imageref 19 | imageStrategy: 20 | helm: 21 | explicitRegistry: true 22 | setValues: 23 | cypress.config.baseUrl: '' 24 | cypress.config.env.BACKEND_API_URL: '' 25 | -------------------------------------------------------------------------------- /src/features/organizations/OrganizationsUtils.ts: -------------------------------------------------------------------------------- 1 | import { OrganizationT } from '@/types/OrganizationsTypes'; 2 | 3 | const compareString = (a: string, b: string): 1 | 0 | -1 => { 4 | if (a < b) { 5 | return -1; 6 | } else if (a === b) { 7 | return 0; 8 | } else { 9 | return 1; 10 | } 11 | }; 12 | 13 | export const compareOrganizations = ( 14 | nodeA: OrganizationT | string, 15 | nodeB: OrganizationT | string 16 | ): 1 | 0 | -1 => { 17 | const nodeALabel = typeof nodeA === 'string' ? nodeA : nodeA.id; 18 | const nodeBLabel = typeof nodeB === 'string' ? nodeB : nodeB.id; 19 | 20 | const res = compareString( 21 | nodeALabel.toLowerCase(), 22 | nodeBLabel.toLowerCase() 23 | ); 24 | if (res === 0) { 25 | return compareString(nodeALabel, nodeBLabel); 26 | } 27 | return res; 28 | }; 29 | -------------------------------------------------------------------------------- /src/api/NewsFeedApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import API, { getApiOptions } from '@/api/request'; 4 | import { API_PATHS } from '@/paths'; 5 | import { PaginatedApiResponseT } from '@/types/CommonTypes'; 6 | import { NewsItemT } from '@/types/NewsFeedTypes'; 7 | 8 | type ListNewsFeedArgsProps = { 9 | page?: number; 10 | pageSize?: number; 11 | timestamp_before?: string; 12 | timestamp_after?: string; 13 | important_news_only?: boolean; 14 | ordering?: 'timestamp' | '-timestamp'; 15 | }; 16 | 17 | export const listNewsFeed = ( 18 | apiListArgs: ListNewsFeedArgsProps, 19 | config: AxiosRequestConfig 20 | ): AxiosPromise> => { 21 | return API.authenticatedGet(API_PATHS.NEWS_FEED, { 22 | ...getApiOptions(apiListArgs), 23 | ...config, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/routes/computePlanDetails/workflow/components/UnavailableWorkflow.tsx: -------------------------------------------------------------------------------- 1 | import { RiEyeOffLine } from 'react-icons/ri'; 2 | 3 | import { useDocumentTitleEffect } from '@/hooks/useDocumentTitleEffect'; 4 | 5 | import EmptyState from '@/components/EmptyState'; 6 | 7 | type UnavailableWorkflowProps = { 8 | subtitle: string; 9 | }; 10 | 11 | const UnavailableWorkflow = ({ 12 | subtitle, 13 | }: UnavailableWorkflowProps): JSX.Element => { 14 | useDocumentTitleEffect( 15 | (setDocumentTitle) => setDocumentTitle('Workflow unavailable'), 16 | [] 17 | ); 18 | 19 | return ( 20 | } 23 | title="Unable to display this workflow" 24 | subtitle={subtitle} 25 | /> 26 | ); 27 | }; 28 | 29 | export default UnavailableWorkflow; 30 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'github-actions' 4 | directory: '/' 5 | schedule: 6 | interval: 'monthly' 7 | groups: 8 | production-dependencies: 9 | dependency-type: "production" 10 | development-dependencies: 11 | dependency-type: "development" 12 | - package-ecosystem: docker 13 | directory: docker 14 | schedule: 15 | interval: weekly 16 | groups: 17 | production-dependencies: 18 | dependency-type: "production" 19 | development-dependencies: 20 | dependency-type: "development" 21 | - package-ecosystem: npm 22 | directory: '/' 23 | schedule: 24 | interval: weekly 25 | groups: 26 | production-dependencies: 27 | dependency-type: "production" 28 | development-dependencies: 29 | dependency-type: "development" 30 | -------------------------------------------------------------------------------- /src/api/BearerTokenApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosResponse } from 'axios'; 2 | 3 | import { RawBearerTokenT, RawNewBearerTokenT } from '@/types/BearerTokenTypes'; 4 | 5 | import API from './request'; 6 | 7 | const URLS = { 8 | API_TOKEN: `/api-token/`, 9 | ACTIVE_API_TOKENS: `/active-api-tokens/`, 10 | }; 11 | 12 | export const requestToken = ( 13 | note: string, 14 | expires_at: string | null 15 | ): Promise> => { 16 | return API.post(URLS.API_TOKEN, { expires_at: expires_at, note: note }); 17 | }; 18 | 19 | export const listActiveTokens = (): Promise< 20 | AxiosResponse<{ tokens: RawBearerTokenT[] }> 21 | > => { 22 | return API.authenticatedGet(URLS.ACTIVE_API_TOKENS); 23 | }; 24 | 25 | export const deleteToken = (id: string): Promise => { 26 | return API.delete(URLS.ACTIVE_API_TOKENS.concat(`?id=`).concat(id)); 27 | }; 28 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "cypress/globals": true 4 | }, 5 | "parser": "@typescript-eslint/parser", 6 | "plugins": ["@typescript-eslint", "cypress"], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:react-hooks/recommended", 11 | "prettier" 12 | ], 13 | "rules": { 14 | "@typescript-eslint/naming-convention": [ 15 | "warn", 16 | { 17 | "selector": "typeAlias", 18 | "format": ["PascalCase"], 19 | "suffix": ["Props", "T"] 20 | } 21 | ], 22 | "eqeqeq": "warn", 23 | "no-unneeded-ternary": "warn", 24 | "no-duplicate-imports": "warn", 25 | "no-console": ["warn", { "allow": ["warn", "error"] }] 26 | }, 27 | "ignorePatterns": ["dist/"] 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/useDocumentTitleEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | type SetDocumentTitleT = (title: string) => void; 4 | 5 | const setDocumentTitle: SetDocumentTitleT = (title) => { 6 | document.title = `${title} - Substra`; 7 | }; 8 | 9 | export const useDocumentTitleEffect = ( 10 | effect: (setDocumentTitle: SetDocumentTitleT) => void, 11 | deps: React.DependencyList 12 | ): void => { 13 | // eslint-disable-next-line react-hooks/exhaustive-deps 14 | useEffect(() => effect(setDocumentTitle), deps); 15 | }; 16 | 17 | export const useAssetListDocumentTitleEffect = ( 18 | title: string, 19 | key: string | null | undefined 20 | ): void => { 21 | useDocumentTitleEffect( 22 | (setDocumentTitle) => { 23 | if (!key) { 24 | setDocumentTitle(title); 25 | } 26 | }, 27 | [key] 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/MarkdownSection.tsx: -------------------------------------------------------------------------------- 1 | import 'github-markdown-css/github-markdown-light.css'; 2 | import ReactMarkdown from 'react-markdown'; 3 | import remarkGfm from 'remark-gfm'; 4 | 5 | import { Box, Heading } from '@chakra-ui/react'; 6 | 7 | type MarkdownSectionProps = { 8 | source: string; 9 | }; 10 | 11 | const MarkdownSection = ({ source }: MarkdownSectionProps): JSX.Element => ( 12 | 13 | ( 18 | 19 | ), 20 | h2: ({ ...props }) => , 21 | }} 22 | /> 23 | 24 | ); 25 | 26 | export default MarkdownSection; 27 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 3000; 3 | server_name localhost; 4 | #access_log /var/log/nginx/host.access.log main; 5 | location / { 6 | root /usr/share/nginx/html; 7 | index index.html index.htm; 8 | try_files $uri /index.html; 9 | } 10 | 11 | location /assets { 12 | root /usr/share/nginx/html; 13 | } 14 | #error_page 404 /404.html; 15 | # redirect server error pages to the static page /50x.html 16 | # 17 | error_page 500 502 503 504 /50x.html; 18 | location = /50x.html { 19 | root /usr/share/nginx/html; 20 | } 21 | # deny access to .htaccess files, if Apache's document root 22 | # concurs with nginx's one 23 | # 24 | #location ~ /\.ht { 25 | # deny all; 26 | #} 27 | server_tokens off; # restrain from giving too much info about the server 28 | } 29 | -------------------------------------------------------------------------------- /src/features/customColumns/CustomColumnsTypes.ts: -------------------------------------------------------------------------------- 1 | import { includesColumn } from './CustomColumnsUtils'; 2 | 3 | export type ColumnT = { 4 | name: string; 5 | type: 'general' | 'metadata'; 6 | }; 7 | 8 | export enum GeneralColumnName { 9 | status = 'Status', 10 | creation = 'Creation', 11 | dates = 'Start date / End date / Duration', 12 | creator = 'Creator', 13 | } 14 | 15 | export const GENERAL_COLUMNS: ColumnT[] = Object.values(GeneralColumnName).map( 16 | (name) => ({ name, type: 'general' }) 17 | ); 18 | 19 | export const isColumn = (column: unknown): column is ColumnT => { 20 | if (typeof column !== 'object') { 21 | return false; 22 | } 23 | 24 | return ( 25 | ((column as ColumnT).type === 'metadata' && 26 | typeof (column as ColumnT).name === 'string') || 27 | includesColumn(GENERAL_COLUMNS, column as ColumnT) 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | This is a file of people that have made significant contributions to the Substra frontend. It is sorted in chronological order. Please include your contribution at the bottom of this document in the following format : name (N), email (E), description of work (W) and date (D). 2 | 3 | To have your contribution listed, your work must meet the minimum [threshold of originality](https://en.wikipedia.org/wiki/Threshold_of_originality), which will be evaluated by the maintainers of the repository. 4 | 5 | Thank you for your contribution, your work is greatly appreciated ! 6 | 7 | —-- Example —-- 8 | 9 | - N: John Doe 10 | - E: john.doe@owkin.com 11 | - W: Integrated new feature 12 | - D: 02/02/2023 13 | 14 | --- 15 | 16 | Copyright (c) 2018-present Owkin Inc. All rights reserved. 17 | 18 | All other contributions: 19 | Copyright (c) 2023 to the respective contributors. 20 | All rights reserved. 21 | -------------------------------------------------------------------------------- /src/routes/users/components/PasswordValidationMessage.tsx: -------------------------------------------------------------------------------- 1 | import { ListItem, ListIcon } from '@chakra-ui/react'; 2 | import { 3 | RiCheckboxBlankCircleLine, 4 | RiCheckLine, 5 | RiCloseLine, 6 | } from 'react-icons/ri'; 7 | 8 | const PasswordValidationMessage = ({ 9 | isEmpty, 10 | isValid, 11 | message, 12 | }: { 13 | isEmpty: boolean; 14 | isValid: boolean; 15 | message: string; 16 | }): JSX.Element => { 17 | let color = 'black'; 18 | let icon = RiCheckboxBlankCircleLine; 19 | 20 | if (!isEmpty) { 21 | color = isValid ? 'primary.500' : 'red.500'; 22 | icon = isValid ? RiCheckLine : RiCloseLine; 23 | } 24 | 25 | return ( 26 | 27 | 28 | {message} 29 | 30 | ); 31 | }; 32 | 33 | export default PasswordValidationMessage; 34 | -------------------------------------------------------------------------------- /charts/substra-frontend/templates/networkpolicy.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: NetworkPolicy 3 | metadata: 4 | name: {{ template "substra-frontend.fullname". }} 5 | labels: 6 | {{ include "substra-frontend.labels" . | nindent 4 }} 7 | spec: 8 | podSelector: 9 | matchLabels: 10 | {{- include "substra-frontend.selectorLabels" . | nindent 6 }} 11 | ingress: 12 | - from: 13 | - ipBlock: 14 | cidr: 0.0.0.0/0 15 | ports: 16 | - port: 3000 17 | protocol: TCP 18 | {{- if eq .Values.service.type "NodePort" }} 19 | {{- if not (empty .Values.service.nodePort) }} 20 | - port: {{.Values.service.nodePort}} 21 | protocol: TCP 22 | {{- else }} 23 | # If not nodePort specified, open the range 24 | - port: 30000 25 | endPort: 32767 26 | protocol: TCP 27 | {{- end }} 28 | {{- end }} 29 | policyTypes: 30 | - Ingress 31 | - Egress 32 | -------------------------------------------------------------------------------- /e2e-tests/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars,no-undef,@typescript-eslint/no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/ComputePlanTaskStatuses.tsx: -------------------------------------------------------------------------------- 1 | import { Flex } from '@chakra-ui/react'; 2 | 3 | import { getStatusCount } from '@/routes/computePlanDetails/ComputePlanUtils'; 4 | import { ComputePlanT } from '@/types/ComputePlansTypes'; 5 | import { taskStatusOrder } from '@/types/TasksTypes'; 6 | 7 | import Status from '@/components/Status'; 8 | 9 | const ComputePlanTaskStatuses = ({ 10 | computePlan, 11 | }: { 12 | computePlan: ComputePlanT; 13 | }): JSX.Element => ( 14 | 15 | {taskStatusOrder.map((status) => ( 16 | 23 | ))} 24 | 25 | ); 26 | export default ComputePlanTaskStatuses; 27 | -------------------------------------------------------------------------------- /src/types/CPWorkflowTypes.ts: -------------------------------------------------------------------------------- 1 | import { TaskStatus } from '@/types/TasksTypes'; 2 | 3 | type PositionT = { 4 | x: number; 5 | y: number; 6 | }; 7 | 8 | type PlugT = { 9 | identifier: string; 10 | kind: string; 11 | }; 12 | 13 | export type TaskT = { 14 | key: string; 15 | rank: number; 16 | function_name: string; 17 | worker: string; 18 | status: TaskStatus; 19 | inputs_specs: PlugT[]; 20 | outputs_specs: PlugT[]; 21 | }; 22 | 23 | export type PositionedTaskT = TaskT & { 24 | position: PositionT; 25 | }; 26 | 27 | type EdgeT = { 28 | source_task_key: string; 29 | source_output_identifier: string; 30 | target_task_key: string; 31 | target_input_identifier: string; 32 | }; 33 | 34 | export type TaskGraphT = { 35 | tasks: TaskT[]; 36 | edges: EdgeT[]; 37 | }; 38 | 39 | export type LayoutedTaskGraphT = { 40 | tasks: PositionedTaskT[]; 41 | edges: EdgeT[]; 42 | }; 43 | -------------------------------------------------------------------------------- /.github/workflows/validate-pr.yml: -------------------------------------------------------------------------------- 1 | name: Validate PR 2 | on: 3 | - pull_request 4 | 5 | jobs: 6 | validate: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: actions/setup-node@v4 11 | with: 12 | node-version: '18.16.0' 13 | - name: Install node modules 14 | run: npm install 15 | - name: ESLint 16 | run: npm run eslint 17 | - name: Prettier 18 | run: npm run prettier 19 | # run this step even if the previous one failed 20 | if: always() 21 | - name: Typescript 22 | run: npx tsc 23 | - name: Knip 24 | run: npm run knip 25 | - name: Unit tests 26 | run: npm run test:unit 27 | - name: Helm 28 | run: helm lint charts/substra-frontend 29 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/functions.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Functions page', () => { 4 | before(() => { 5 | cy.login(); 6 | }); 7 | 8 | beforeEach(() => { 9 | cy.visit('/functions'); 10 | }); 11 | 12 | it('lists functions', () => { 13 | cy.get('tbody[data-cy=loaded]') 14 | .get('tr') 15 | .should('have.length.greaterThan', 2); 16 | }); 17 | 18 | it('functions pagination', () => { 19 | cy.paginationTest(); 20 | }); 21 | 22 | it('open filters', () => { 23 | cy.checkOpenFilters(0); 24 | }); 25 | 26 | it('can filter functions by owner', () => { 27 | cy.checkFilterAssetsBy('owner'); 28 | }); 29 | 30 | it('display a function drawer', () => { 31 | cy.checkOpenDrawer(); 32 | }); 33 | 34 | it('searches function with a key', () => { 35 | cy.checkSearchByKey('functions'); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/features/docs/useReleasesInfoStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from 'zustand'; 2 | 3 | import { retrieveSubstraReleases } from '@/api/DocsApi'; 4 | import { ReleasesInfoT } from '@/types/DocsTypes'; 5 | 6 | type ReleasesInfoStateT = { 7 | info: ReleasesInfoT | null; 8 | 9 | fetchingInfo: boolean; 10 | 11 | fetchInfo: () => void; 12 | }; 13 | 14 | const useReleasesInfoStore = create((set) => ({ 15 | info: null, 16 | fetchingInfo: true, 17 | 18 | fetchInfo: async () => { 19 | set({ fetchingInfo: true }); 20 | try { 21 | const response = await retrieveSubstraReleases(); 22 | set({ 23 | fetchingInfo: false, 24 | info: response.data, 25 | }); 26 | } catch (error) { 27 | console.warn(error); 28 | set({ fetchingInfo: false }); 29 | } 30 | }, 31 | })); 32 | 33 | export default useReleasesInfoStore; 34 | -------------------------------------------------------------------------------- /src/hooks/useWithAbortController.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | 3 | const useWithAbortController = () => { 4 | const abortControllerRef = useRef(null); 5 | 6 | const withAbortController = useCallback( 7 | ( 8 | callback: (abortController: AbortController) => void 9 | ): (() => void) => { 10 | if (abortControllerRef.current) { 11 | abortControllerRef.current.abort(); 12 | } 13 | abortControllerRef.current = new AbortController(); 14 | 15 | callback(abortControllerRef.current); 16 | 17 | return () => { 18 | if (abortControllerRef.current) { 19 | abortControllerRef.current.abort(); 20 | } 21 | }; 22 | }, 23 | [abortControllerRef] 24 | ); 25 | return withAbortController; 26 | }; 27 | export default useWithAbortController; 28 | -------------------------------------------------------------------------------- /src/types/ProfilingTypes.ts: -------------------------------------------------------------------------------- 1 | export enum TaskStep { 2 | inputsPreparation = 'prepare_inputs', 3 | functionDownloading = 'download_function', 4 | taskExecution = 'task_execution', 5 | outputsSaving = 'save_outputs', 6 | } 7 | 8 | export enum FunctionStep { 9 | imageBuilding = 'build_image', 10 | functionSaving = 'save_function', 11 | } 12 | 13 | export type AllStepsT = TaskStep | FunctionStep; 14 | 15 | export type StepT = { 16 | step: StepType; 17 | duration: number; // in microseconds 18 | }; 19 | 20 | export type StepInfoT = { 21 | title: string; 22 | color: string; 23 | description: string; 24 | }; 25 | 26 | export type ExecutionRundownT = { 27 | execution_rundown: StepT[]; 28 | }; 29 | 30 | export type FunctionExecutionRundownT = ExecutionRundownT; 31 | export type TaskExecutionRundownT = ExecutionRundownT; 32 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/datasets.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Datasets page', () => { 4 | before(() => { 5 | cy.login(); 6 | }); 7 | 8 | beforeEach(() => { 9 | cy.visit('/datasets'); 10 | }); 11 | 12 | it('lists datasets', () => { 13 | cy.get('tbody[data-cy=loaded]') 14 | .get('tr') 15 | .should('have.length.greaterThan', 2); 16 | }); 17 | 18 | it('opens filters', () => { 19 | cy.checkOpenFilters(0); 20 | }); 21 | 22 | it('can filter datasets by owner', () => { 23 | cy.checkFilterAssetsBy('owner'); 24 | }); 25 | 26 | it('navigates to the dedicated dataset page', () => { 27 | cy.get('tbody[data-cy=loaded]').get('tr').eq(1).click({ force: true }); 28 | cy.url().should('match', /datasets\/.{36}/); 29 | }); 30 | 31 | it('searches dataset with a key', () => { 32 | cy.checkSearchByKey('datasets'); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/features/copy/CopyIconButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | IconButton, 3 | IconButtonProps, 4 | Tooltip, 5 | useClipboard, 6 | } from '@chakra-ui/react'; 7 | import { RiCheckLine, RiFileCopyLine } from 'react-icons/ri'; 8 | 9 | type CopyIconButtonProps = IconButtonProps & { 10 | value: string; 11 | }; 12 | const CopyIconButton = ({ 13 | value, 14 | ...props 15 | }: CopyIconButtonProps): JSX.Element => { 16 | const { hasCopied, onCopy } = useClipboard(value); 17 | 18 | return ( 19 | 25 | : } 29 | onClick={onCopy} 30 | /> 31 | 32 | ); 33 | }; 34 | export default CopyIconButton; 35 | -------------------------------------------------------------------------------- /src/routes/functions/FunctionsUtils.ts: -------------------------------------------------------------------------------- 1 | import { AssetKindT } from '@/types/FunctionsTypes'; 2 | import { FunctionStep, StepInfoT } from '@/types/ProfilingTypes'; 3 | 4 | const ASSET_KIND_LABELS: Record = { 5 | ASSET_DATA_SAMPLE: 'data sample', 6 | ASSET_MODEL: 'model', 7 | ASSET_DATA_MANAGER: 'dataset', 8 | ASSET_PERFORMANCE: 'performance', 9 | }; 10 | export const getAssetKindLabel = (kind: AssetKindT): string => 11 | ASSET_KIND_LABELS[kind]; 12 | 13 | // Order is used for ordering function 14 | export const functionStepsInfo: Record = { 15 | [FunctionStep.imageBuilding]: { 16 | title: 'Building image', 17 | color: 'primary.500', 18 | description: 'Build the Docker image.', 19 | }, 20 | [FunctionStep.functionSaving]: { 21 | title: 'Saving function', 22 | color: 'orange.500', 23 | description: 'Save the function in the local object repository.', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfLoadingState.tsx: -------------------------------------------------------------------------------- 1 | import { VStack, Wrap, WrapItem, Skeleton } from '@chakra-ui/react'; 2 | 3 | const PerfLoadingState = (): JSX.Element => { 4 | return ( 5 | 14 | 15 | {[...Array(4)].map((_, index) => ( 16 | 17 | 22 | 23 | ))} 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default PerfLoadingState; 30 | -------------------------------------------------------------------------------- /src/routes/users/components/UserAwaitingApprovalPage.tsx: -------------------------------------------------------------------------------- 1 | import { VStack, Center } from '@chakra-ui/react'; 2 | import { RiTimeLine } from 'react-icons/ri'; 3 | 4 | import { useDocumentTitleEffect } from '@/hooks/useDocumentTitleEffect'; 5 | 6 | import EmptyState from '@/components/EmptyState'; 7 | 8 | const UserAwaitingApprovalPage = (): JSX.Element => { 9 | useDocumentTitleEffect( 10 | (setDocumentTitle) => setDocumentTitle('Waiting for access'), 11 | [] 12 | ); 13 | return ( 14 |
15 | 16 | } 18 | title="Waiting for your access to be validated" 19 | subtitle="Your administrator has received your demand, please reach out to them in case you have any issue." 20 | /> 21 | 22 |
23 | ); 24 | }; 25 | export default UserAwaitingApprovalPage; 26 | -------------------------------------------------------------------------------- /e2e-tests/cypress/e2e/tasks.cy.js: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | describe('Tasks page', () => { 4 | before(() => { 5 | cy.login(); 6 | }); 7 | 8 | beforeEach(() => { 9 | cy.visit('/tasks'); 10 | }); 11 | 12 | it('lists tasks', () => { 13 | cy.get('tbody[data-cy=loaded]') 14 | .get('tr') 15 | .should('have.length.greaterThan', 2); 16 | }); 17 | 18 | it('functions pagination', () => { 19 | cy.paginationTest(); 20 | }); 21 | 22 | it('opens filters', () => { 23 | cy.checkOpenFilters(0); 24 | }); 25 | 26 | it('can filter tasks by status', () => { 27 | cy.checkFilterAssetsBy('status'); 28 | }); 29 | 30 | it('displays a task drawer', () => { 31 | cy.checkOpenDrawer(); 32 | }); 33 | 34 | it('task drawer shows performance', () => { 35 | cy.getDataCy('task-with-performance').first().click(); 36 | cy.getDataCy('output-performance').should('exist'); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | }, 7 | "target": "ESNext", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": [ 10 | "node", 11 | "vite/client", 12 | "@emotion/react/types/css-prop", 13 | "jest", 14 | "@types/react" 15 | ], 16 | "typeRoots": ["node_modules/@types", "node_modules"], 17 | "allowJs": false, 18 | "skipLibCheck": true, 19 | "esModuleInterop": false, 20 | "allowSyntheticDefaultImports": true, 21 | "strict": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "module": "ESNext", 24 | "moduleResolution": "Node", 25 | "resolveJsonModule": true, 26 | "isolatedModules": true, 27 | "noEmit": true, 28 | "jsx": "react-jsx" 29 | }, 30 | "include": ["./src"], 31 | "exclude": ["node_modules", "**/*.test.tsx?"] 32 | } 33 | -------------------------------------------------------------------------------- /e2e-tests/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | env: { 5 | USERNAME: 'org-1', 6 | PASSWORD: 'p@sswr0d44', 7 | BACKEND_API_URL: 'http://substra-backend.org-1.com:8000', 8 | DEFAULT_PAGE_SIZE: 30, 9 | }, 10 | viewportWidth: 1440, 11 | viewportHeight: 900, 12 | video: false, 13 | defaultCommandTimeout: 20000, 14 | e2e: { 15 | setupNodeEvents(on) { 16 | on('task', { 17 | // To see log messages in the terminal during cypress run 18 | // cy.task("log", "my message") 19 | log(message) { 20 | // eslint-disable-next-line no-console 21 | console.log(message + '\n\n'); 22 | return null; 23 | }, 24 | }); 25 | }, 26 | baseUrl: 'http://substra-frontend.org-1.com:3000', 27 | }, 28 | retries: { 29 | runMode: 2, 30 | openMode: 1, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/features/cookies/useCookie.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import Cookies from 'universal-cookie'; 4 | 5 | export const toBool = (value: string | undefined): boolean | undefined => { 6 | if (value === undefined) { 7 | return undefined; 8 | } 9 | 10 | if (value === 'true') { 11 | return true; 12 | } 13 | 14 | if (value === 'false') { 15 | return false; 16 | } 17 | throw `Cannot convert value to boolean: ${value}`; 18 | }; 19 | 20 | const useCookie = ( 21 | cookieName: string, 22 | cast: (value: string | undefined) => Type 23 | ): [Type, (value: Type) => void] => { 24 | const cookies = new Cookies(); 25 | 26 | // useState is required so that updating cookie is reactive 27 | const [value, setValue] = useState(() => cast(cookies.get(cookieName))); 28 | 29 | const setCookie = (newValue: Type) => { 30 | cookies.set(cookieName, newValue); 31 | setValue(newValue); 32 | }; 33 | 34 | return [value, setCookie]; 35 | }; 36 | 37 | export default useCookie; 38 | -------------------------------------------------------------------------------- /src/api/FunctionsApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise, AxiosRequestConfig } from 'axios'; 2 | 3 | import API, { getApiOptions } from '@/api/request'; 4 | import { API_PATHS, compilePath } from '@/paths'; 5 | import { APIListArgsT, PaginatedApiResponseT } from '@/types/CommonTypes'; 6 | import { FunctionT } from '@/types/FunctionsTypes'; 7 | 8 | export const listFunctions = ( 9 | apiListArgs: APIListArgsT, 10 | config: AxiosRequestConfig 11 | ): AxiosPromise> => 12 | API.authenticatedGet(API_PATHS.FUNCTIONS, { 13 | ...getApiOptions(apiListArgs), 14 | ...config, 15 | }); 16 | 17 | export const retrieveFunction = ( 18 | key: string, 19 | config: AxiosRequestConfig 20 | ): AxiosPromise => 21 | API.authenticatedGet(compilePath(API_PATHS.FUNCTION, { key }), config); 22 | 23 | export const updateFunction = ( 24 | key: string, 25 | func: { name: string }, 26 | config: AxiosRequestConfig 27 | ): AxiosPromise => 28 | API.put(compilePath(API_PATHS.FUNCTION, { key }), func, config); 29 | -------------------------------------------------------------------------------- /src/api/MeApi.ts: -------------------------------------------------------------------------------- 1 | import { AxiosPromise } from 'axios'; 2 | 3 | import API from '@/api/request'; 4 | import { API_PATHS } from '@/paths'; 5 | import { MeInfoT } from '@/types/MeTypes'; 6 | 7 | export type LoginPayloadT = { 8 | username: string; 9 | password: string; 10 | }; 11 | 12 | export type LoginDataT = { 13 | exp: number; 14 | jti: string; 15 | token_type: string; 16 | user_id: number; 17 | }; 18 | 19 | export const postLogIn = (payload: LoginPayloadT): AxiosPromise => { 20 | return API.post(API_PATHS.LOGIN, payload); 21 | }; 22 | 23 | export const getLogOut = (): AxiosPromise => { 24 | return API.get(API_PATHS.LOGOUT); 25 | }; 26 | 27 | export const refreshToken = (): AxiosPromise => { 28 | return API.post(API_PATHS.REFRESH); 29 | }; 30 | 31 | export const retrieveInfo = ( 32 | withCredentials: boolean 33 | ): AxiosPromise => { 34 | if (withCredentials) { 35 | return API.authenticatedGet(API_PATHS.INFO); 36 | } 37 | 38 | return API.anonymousGet(API_PATHS.INFO); 39 | }; 40 | -------------------------------------------------------------------------------- /src/types/BearerTokenTypes.ts: -------------------------------------------------------------------------------- 1 | export type BearerTokenT = { 2 | created_at: Date; 3 | expires_at: Date; 4 | note: string; 5 | id: string; 6 | }; 7 | export type NewBearerTokenT = BearerTokenT & { token: string }; 8 | 9 | // API response 10 | export type RawBearerTokenT = { 11 | created_at: string; 12 | expires_at: string; 13 | note: string; 14 | id: string; 15 | }; 16 | export type RawNewBearerTokenT = RawBearerTokenT & { token: string }; 17 | 18 | export const isNewBearerTokenT = ( 19 | newBearerToken: unknown 20 | ): newBearerToken is NewBearerTokenT => { 21 | if (typeof newBearerToken !== 'object') { 22 | return false; 23 | } 24 | 25 | return ( 26 | (newBearerToken as NewBearerTokenT).created_at !== undefined && 27 | (newBearerToken as NewBearerTokenT).expires_at !== undefined && 28 | (newBearerToken as NewBearerTokenT).id !== undefined && 29 | (newBearerToken as NewBearerTokenT).note !== undefined && 30 | (newBearerToken as NewBearerTokenT).token !== undefined 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /src/routes/functions/components/DescriptionDrawerSection.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | 3 | import { Skeleton, Text } from '@chakra-ui/react'; 4 | 5 | import { DrawerSection } from '@/components/DrawerSection'; 6 | 7 | const MarkdownSection = React.lazy( 8 | () => import('@/components/MarkdownSection') 9 | ); 10 | 11 | type DescriptionDrawerSectionProps = { 12 | description?: string; 13 | loading: boolean; 14 | }; 15 | 16 | const DescriptionDrawerSection = ({ 17 | description, 18 | loading, 19 | }: DescriptionDrawerSectionProps): JSX.Element => { 20 | return ( 21 | 22 | {loading && } 23 | {!loading && !description && N/A} 24 | {!loading && description && ( 25 | }> 26 | 27 | 28 | )} 29 | 30 | ); 31 | }; 32 | export default DescriptionDrawerSection; 33 | -------------------------------------------------------------------------------- /.github/workflows/helm.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Helm 2 | # This workflow builds the 'main' helm 3 | # That is, any helm chart change that gets to main is immediately uploaded to the 4 | # chart repo (that is to say it is released without further ado) 5 | env: {} 6 | on: 7 | push: 8 | branches: 9 | - "main" 10 | paths: 11 | - "charts/substra-frontend/**" 12 | pull_request: 13 | branches: 14 | - "main" 15 | paths: 16 | - "charts/substra-frontend/**" 17 | workflow_dispatch: 18 | inputs: 19 | publish-alpha: 20 | description: | 21 | Publish alpha chart. Chart version must be X.Y.Z-alpha.N. 22 | type: boolean 23 | required: false 24 | default: false 25 | 26 | concurrency: 27 | group: "${{ github.workflow }}-${{ github.ref }}" 28 | cancel-in-progress: true 29 | 30 | jobs: 31 | helm: 32 | name: Helm 33 | uses: substra/substra-gha-workflows/.github/workflows/helm.yml@main 34 | with: 35 | helm-repositories: '' 36 | publish-alpha: ${{ inputs.publish-alpha == true }} 37 | secrets: inherit 38 | -------------------------------------------------------------------------------- /docker/substra-frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.20-bookworm-slim AS common 2 | 3 | ARG APP_VERSION 4 | 5 | WORKDIR /workspace 6 | 7 | COPY package.json package-lock.json ./ 8 | RUN npm install --unsafe-perm 9 | 10 | 11 | FROM common as dev 12 | 13 | COPY src public index.html vite.config.ts tsconfig.json ./ 14 | CMD [ "npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000" ] 15 | 16 | 17 | FROM common as build 18 | 19 | COPY . . 20 | ENV NODE_ENV=production 21 | RUN npm run build 22 | 23 | 24 | FROM nginx:1.25.4 25 | 26 | COPY ./nginx/nginx.conf /etc/nginx/conf.d/default.conf 27 | COPY --from=build /workspace/dist /usr/share/nginx/html 28 | 29 | RUN apt-get update -y \ 30 | && apt-get install -y --no-install-recommends glibc-source=2.36-9+deb12u7 \ 31 | && apt-get --allow-remove-essential --auto-remove remove perl-base libexpat1 libaom3 libgssapi-krb5-2 libk5crypto3 libkrb5-3 libkrb5support0 util-linux-extra util-linux mount libmount1 -y\ 32 | && apt-get clean \ 33 | && rm -rf /var/lib/apt/lists/* \ 34 | && mv /usr/share/nginx/html/index.html /usr/share/nginx/html/index-template.html 35 | -------------------------------------------------------------------------------- /src/hooks/useSelection.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | export type OnOptionChangeT = ( 4 | value: string 5 | ) => (event: React.ChangeEvent) => void; 6 | 7 | const useSelection = ( 8 | initialSelection: string[] = [] 9 | ): [string[], OnOptionChangeT, () => void, (values: string[]) => void] => { 10 | const [selection, setSelection] = useState(initialSelection); 11 | 12 | const onSelectionChange = 13 | (value: string) => (event: React.ChangeEvent) => { 14 | const checked = event.target.checked; 15 | const selected = selection.includes(value); 16 | 17 | if (checked && !selected) { 18 | setSelection([...selection, value]); 19 | } 20 | 21 | if (!checked && selected) { 22 | setSelection(selection.filter((v) => v !== value)); 23 | } 24 | }; 25 | 26 | const resetSelection = () => { 27 | setSelection([]); 28 | }; 29 | 30 | return [selection, onSelectionChange, resetSelection, setSelection]; 31 | }; 32 | 33 | export default useSelection; 34 | -------------------------------------------------------------------------------- /src/types/MetadataTypes.ts: -------------------------------------------------------------------------------- 1 | export type MetadataFilterT = 'contains' | 'exists' | 'is'; 2 | 3 | export type MetadataFilterPropsT = { 4 | key: string; 5 | type: MetadataFilterT; 6 | value?: string; 7 | }; 8 | 9 | export type MetadataFilterWithUuidT = MetadataFilterPropsT & { 10 | uuid: string; 11 | }; 12 | 13 | export const isMetadataFilter = ( 14 | filter: unknown 15 | ): filter is MetadataFilterPropsT => { 16 | if (typeof filter !== 'object' || !filter) { 17 | return false; 18 | } 19 | 20 | if ( 21 | !(filter as MetadataFilterPropsT).key || 22 | !(filter as MetadataFilterPropsT).type 23 | ) { 24 | return false; 25 | } 26 | if ( 27 | ((filter as MetadataFilterPropsT).type === 'is' || 28 | (filter as MetadataFilterPropsT).type === 'contains') && 29 | !(filter as MetadataFilterPropsT).value 30 | ) { 31 | return false; 32 | } 33 | if ( 34 | (filter as MetadataFilterPropsT).type === 'exists' && 35 | (filter as MetadataFilterPropsT).value 36 | ) { 37 | return false; 38 | } 39 | return true; 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/MetadataDrawerSection.tsx: -------------------------------------------------------------------------------- 1 | import { SkeletonText, Text } from '@chakra-ui/react'; 2 | 3 | import { DrawerSection, DrawerSectionEntry } from '@/components/DrawerSection'; 4 | 5 | export default ({ 6 | metadata, 7 | loading, 8 | }: { 9 | metadata: Record | undefined; 10 | loading?: boolean; 11 | }): JSX.Element => { 12 | if (loading || !metadata) { 13 | return ( 14 | 15 | 16 | 17 | ); 18 | } 19 | if (Object.keys(metadata).length === 0) { 20 | return ( 21 | 22 | N/A 23 | 24 | ); 25 | } 26 | return ( 27 | 28 | {Object.entries(metadata).map(([key, value]) => ( 29 | 30 | {value} 31 | 32 | ))} 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /src/routes/dataset/components/BreadCrumbs.tsx: -------------------------------------------------------------------------------- 1 | import { BreadcrumbItem, Text } from '@chakra-ui/react'; 2 | import { RiDatabase2Line } from 'react-icons/ri'; 3 | 4 | import { PATHS } from '@/paths'; 5 | 6 | import Breadcrumbs from '@/components/Breadcrumbs'; 7 | 8 | import useDatasetStore from '../useDatasetStore'; 9 | 10 | const DatasetBreadcrumbs = (): JSX.Element => { 11 | const { dataset, fetchingDataset } = useDatasetStore(); 12 | return ( 13 | 18 | 19 | 25 | {fetchingDataset && 'Loading'} 26 | {!fetchingDataset && dataset && <>{dataset.name}} 27 | 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default DatasetBreadcrumbs; 34 | -------------------------------------------------------------------------------- /src/routes/functions/components/FunctionDurationBar.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import ProfilingDurationBar from '@/components/ProfilingDurationBar'; 4 | 5 | import { functionStepsInfo } from '../FunctionsUtils'; 6 | import useFunctionStore from '../useFunctionStore'; 7 | 8 | const FunctionDurationBar = ({ 9 | functionKey, 10 | }: { 11 | functionKey: string | null | undefined; 12 | }): JSX.Element => { 13 | const { 14 | functionProfiling, 15 | fetchingFunctionProfiling, 16 | fetchFunctionProfiling, 17 | } = useFunctionStore(); 18 | 19 | useEffect(() => { 20 | if (functionKey) { 21 | fetchFunctionProfiling(functionKey); 22 | } 23 | }, [fetchFunctionProfiling, functionKey]); 24 | 25 | return ( 26 | 33 | ); 34 | }; 35 | 36 | export default FunctionDurationBar; 37 | -------------------------------------------------------------------------------- /src/types/FunctionsTypes.ts: -------------------------------------------------------------------------------- 1 | import { FileT, MetadataT, PermissionsT } from '@/types/CommonTypes'; 2 | import { ModelT } from '@/types/ModelsTypes'; 3 | 4 | import { FunctionExecutionRundownT } from './ProfilingTypes'; 5 | 6 | export enum AssetKindT { 7 | dataSample = 'ASSET_DATA_SAMPLE', 8 | model = 'ASSET_MODEL', 9 | dataManager = 'ASSET_DATA_MANAGER', 10 | performance = 'ASSET_PERFORMANCE', 11 | } 12 | 13 | export type FunctionInputT = { 14 | kind: AssetKindT; 15 | multiple: boolean; 16 | optional: boolean; 17 | }; 18 | 19 | type FunctionOutputT = { 20 | kind: AssetKindT; 21 | multiple: boolean; 22 | value: number | ModelT; 23 | }; 24 | 25 | export type FunctionT = { 26 | key: string; 27 | name: string; 28 | owner: string; 29 | permissions: PermissionsT; 30 | description: FileT; 31 | archive: FileT; 32 | metadata: MetadataT; 33 | creation_date: string; 34 | inputs: { [name: string]: FunctionInputT }; 35 | outputs: { [name: string]: FunctionOutputT }; 36 | }; 37 | 38 | export type FunctionProfilingT = { 39 | function_key: string; 40 | duration: number; // in microseconds 41 | } & FunctionExecutionRundownT; 42 | -------------------------------------------------------------------------------- /src/routes/users/components/RoleInput.tsx: -------------------------------------------------------------------------------- 1 | import { Select } from '@chakra-ui/react'; 2 | 3 | import { capitalize } from '@/libs/utils'; 4 | import { UserRolesT } from '@/types/UsersTypes'; 5 | 6 | import { DrawerSectionEntry } from '@/components/DrawerSection'; 7 | 8 | type RoleInputProps = { 9 | value: UserRolesT; 10 | onChange: (value: UserRolesT) => void; 11 | isDisabled?: boolean; 12 | }; 13 | 14 | const RoleInput = ({ 15 | value, 16 | onChange, 17 | isDisabled, 18 | }: RoleInputProps): JSX.Element => { 19 | return ( 20 | 21 | 34 | 35 | ); 36 | }; 37 | 38 | export default RoleInput; 39 | -------------------------------------------------------------------------------- /src/libs/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { getDiffDates } from './utils'; 2 | 3 | test('getDiffDates', () => { 4 | const start = '2022-01-01 00:00:00'; 5 | // seconds only 6 | expect(getDiffDates(start, '2022-01-01 00:00:01')).toBe('00h 00min 01s'); 7 | expect(getDiffDates(start, '2022-01-01 00:00:10')).toBe('00h 00min 10s'); 8 | // minutes only 9 | expect(getDiffDates(start, '2022-01-01 00:01:00')).toBe('00h 01min 00s'); 10 | expect(getDiffDates(start, '2022-01-01 00:10:00')).toBe('00h 10min 00s'); 11 | // hours only 12 | expect(getDiffDates(start, '2022-01-01 01:00:00')).toBe('01h 00min 00s'); 13 | expect(getDiffDates(start, '2022-01-01 10:00:00')).toBe('10h 00min 00s'); 14 | // days only 15 | expect(getDiffDates(start, '2022-01-02 00:00:00')).toBe( 16 | '1 day, 00h 00min 00s' 17 | ); 18 | expect(getDiffDates(start, '2022-01-11 00:00:00')).toBe( 19 | '10 days, 00h 00min 00s' 20 | ); 21 | expect(getDiffDates(start, '2022-02-01 00:00:00')).toBe( 22 | '31 days, 00h 00min 00s' 23 | ); 24 | // everything 25 | expect(getDiffDates(start, '2022-02-10 10:10:10')).toBe( 26 | '40 days, 10h 10min 10s' 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/features/perfBrowser/PerfChartTooltip.tsx: -------------------------------------------------------------------------------- 1 | import { List } from '@chakra-ui/react'; 2 | 3 | import PerfChartTooltipItem from '@/features/perfBrowser/PerfChartTooltipItem'; 4 | import { DataPointT } from '@/types/SeriesTypes'; 5 | 6 | const TOOLTIP_WIDTH = 340; 7 | 8 | type PerfChartTooltipProps = { 9 | x: number; 10 | y: number; 11 | showTooltip: () => void; 12 | hideTooltip: () => void; 13 | points: DataPointT[]; 14 | }; 15 | 16 | const PerfChartTooltip = ({ 17 | x, 18 | y, 19 | showTooltip, 20 | hideTooltip, 21 | points, 22 | }: PerfChartTooltipProps): JSX.Element => ( 23 | 36 | {points.map((point) => ( 37 | 38 | ))} 39 | 40 | ); 41 | 42 | export default PerfChartTooltip; 43 | -------------------------------------------------------------------------------- /charts/substra-frontend/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.ingress.enabled -}} 2 | {{- $fullName := include "substra-frontend.fullname" . -}} 3 | {{- $svcPort := .Values.service.port -}} 4 | apiVersion: networking.k8s.io/v1 5 | kind: Ingress 6 | metadata: 7 | name: {{ $fullName }} 8 | labels: 9 | {{- include "substra-frontend.labels" . | nindent 4 }} 10 | {{- with .Values.ingress.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | spec: 15 | {{- if .Values.ingress.tls }} 16 | tls: 17 | {{- range .Values.ingress.tls }} 18 | - hosts: 19 | {{- range .hosts }} 20 | - {{ . | quote }} 21 | {{- end }} 22 | secretName: {{ .secretName }} 23 | {{- end }} 24 | {{- end }} 25 | rules: 26 | {{- range .Values.ingress.hosts }} 27 | - host: {{ .host | quote }} 28 | http: 29 | paths: 30 | {{- range .paths }} 31 | - path: {{ . }} 32 | pathType: Prefix 33 | backend: 34 | service: 35 | name: {{ $fullName }} 36 | port: 37 | number: {{ $svcPort }} 38 | {{- end }} 39 | {{- end }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /src/hooks/useFavoriteComputePlans.tsx: -------------------------------------------------------------------------------- 1 | import { useLocalStorageStringArrayState } from '@/hooks/useLocalStorageState'; 2 | 3 | const useFavoriteComputePlans = (): { 4 | favorites: string[]; 5 | setFavorites: (favorites: string[]) => void; 6 | addToFavorites: (favorite: string) => void; 7 | removeFromFavorites: (favorite: string) => void; 8 | isFavorite: (cpKey: string) => boolean; 9 | onFavoriteChange: (cpKey: string) => () => void; 10 | } => { 11 | const { 12 | state: favorites, 13 | setState: setFavorites, 14 | includesItem: isFavorite, 15 | addItem: addToFavorites, 16 | removeItem: removeFromFavorites, 17 | } = useLocalStorageStringArrayState('favorite_compute_plans'); 18 | 19 | const onFavoriteChange = (cpKey: string) => () => { 20 | if (isFavorite(cpKey)) { 21 | removeFromFavorites(cpKey); 22 | } else { 23 | addToFavorites(cpKey); 24 | } 25 | }; 26 | 27 | return { 28 | favorites, 29 | setFavorites, 30 | addToFavorites, 31 | removeFromFavorites, 32 | isFavorite, 33 | onFavoriteChange, 34 | }; 35 | }; 36 | export default useFavoriteComputePlans; 37 | -------------------------------------------------------------------------------- /src/routes/computePlanDetails/components/CancelComputePlanMenuItem.tsx: -------------------------------------------------------------------------------- 1 | import { MenuItemProps, MenuItem, Tooltip } from '@chakra-ui/react'; 2 | 3 | type CancelComputePlanMenuItemProps = { 4 | onClick: MenuItemProps['onClick']; 5 | hasPermissions: boolean; 6 | hasCancellableStatus: boolean; 7 | }; 8 | const CancelComputePlanMenuItem = ({ 9 | onClick, 10 | hasPermissions, 11 | hasCancellableStatus, 12 | }: CancelComputePlanMenuItemProps) => { 13 | const isEnabled = hasPermissions && hasCancellableStatus; 14 | let label; 15 | 16 | if (isEnabled) { 17 | return Cancel execution; 18 | } else if (hasPermissions) { 19 | label = 'This compute plan cannot be canceled because of its state'; 20 | } else { 21 | label = 22 | "This compute plan cannot be canceled because you don't have permission to"; 23 | } 24 | return ( 25 | 26 | 27 | Cancel execution 28 | 29 | 30 | ); 31 | }; 32 | 33 | export default CancelComputePlanMenuItem; 34 | -------------------------------------------------------------------------------- /src/types/CommonTypes.ts: -------------------------------------------------------------------------------- 1 | export type PermissionT = { 2 | public: boolean; 3 | authorized_ids: string[]; 4 | }; 5 | 6 | export type PermissionsT = { 7 | process: PermissionT; 8 | download: PermissionT; 9 | }; 10 | 11 | export const ASSET_LABEL: Record = { 12 | function: 'function', 13 | dataset: 'dataset', 14 | task: 'task', 15 | compute_plan: 'compute plan', 16 | user: 'user', 17 | }; 18 | 19 | export type AssetT = 'dataset' | 'function' | 'task' | 'compute_plan' | 'user'; 20 | 21 | export type PaginatedApiResponseT = { 22 | count: number; 23 | next: string | null; 24 | previous: string | null; 25 | results: T[]; 26 | }; 27 | 28 | export type MetadataT = Record; 29 | 30 | export type FileT = { 31 | checksum: string; 32 | storage_address: string; 33 | }; 34 | 35 | export type HasKeyT = { 36 | key: string; 37 | }; 38 | 39 | export type APIListArgsT = { 40 | page?: number; 41 | ordering?: string; 42 | pageSize?: number; 43 | match?: string; 44 | } & { [param: string]: unknown }; 45 | 46 | export type APIRetrieveListArgsT = APIListArgsT & { 47 | key: string; 48 | }; 49 | 50 | export type AbortFunctionT = () => void; 51 | -------------------------------------------------------------------------------- /ci/readme.md: -------------------------------------------------------------------------------- 1 | ## Build 2 | 3 | On the CI, they are built continuously by GitHub Actions workflow called [build.yaml](/.github/workflows/build.yaml), which gives them "dev" versions based on commit info. This workflow can be triggered by: 4 | 5 | - pushing a commit on the `main` branch 6 | - pushing a tag 7 | - [running it manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) via the github interface or a HTTP API call 8 | 9 | The registry is `ghcr.io/substra` 10 | 11 | The helm chart repository is `https://substra.github.io/charts/substra-frontend`. 12 | 13 | ## Release 14 | 15 | ### App (docker images) 16 | 17 | When a tag is pushed, it triggers the [release.yaml](/.github/workflows/release.yaml) workflow, which builds an images with the same tag. 18 | 19 | ### Helm 20 | 21 | Helm charts do not follow the regular release process. Any change to the `charts/` directory to the main branch will trigger a build and upload. 22 | 23 | ## PR validation (linting) 24 | 25 | See [validate-pr.yaml](/.github/workflows/validate-pr.yaml) 26 | 27 | ## End-to-end tests 28 | 29 | End-to-end tests are hosted here, but the CI that runs them is on [substra-tests](https://github.com/Substra/substra-tests) 30 | -------------------------------------------------------------------------------- /src/routes/computePlans/components/CheckboxTd.tsx: -------------------------------------------------------------------------------- 1 | import { Box, TableCellProps, Td } from '@chakra-ui/react'; 2 | 3 | type CheckboxTdProps = TableCellProps & { 4 | firstCol?: boolean; 5 | }; 6 | const CheckboxTd = ({ 7 | firstCol, 8 | children, 9 | ...props 10 | }: CheckboxTdProps): JSX.Element => ( 11 |
19 | e.stopPropagation()} 33 | > 34 | {children} 35 | 36 |
29 | 30 | {computePlans.length > 1 && ( 31 | 32 | #{getComputePlanIndex(computePlan.key)} 33 | 34 | )} 35 | {computePlan.name} 36 | 37 | 44 | {computePlan.metadata[column] || '-'} 45 |