mirror of
https://github.com/quay/quay.git
synced 2025-04-18 10:44:06 +03:00
ui: run builds (PROJQUAY-6297) (#2636)
Adds the ability for users to run builds via the Patternfly UI.
This commit is contained in:
parent
e6bf6392aa
commit
417e66ee76
@ -23,7 +23,7 @@ repos:
|
||||
entry: web/node_modules/.bin/eslint --fix
|
||||
language: system
|
||||
files: ^web/
|
||||
exclude: ^web/cypress/test/
|
||||
exclude: ^web/cypress/test/|^web/cypress/fixtures
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
|
@ -464,7 +464,7 @@ describe('Repository Builds', () => {
|
||||
}).as('getBuildTriggers');
|
||||
cy.intercept(
|
||||
'PUT',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/gitlab82-9fd5-4005-bc95-d3156855f0d5',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/disabled-9fd5-4005-bc95-d3156855f0d5',
|
||||
{
|
||||
statusCode: 200,
|
||||
},
|
||||
@ -473,7 +473,7 @@ describe('Repository Builds', () => {
|
||||
cy.visit('/repository/testorg/testrepo?tab=builds');
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to GitLab repository testgitorg/testgitrepo',
|
||||
'push to GitLab repository testgitorg/disabledrepo',
|
||||
).within(() => {
|
||||
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
|
||||
cy.contains('Enable Trigger').click();
|
||||
@ -540,7 +540,12 @@ describe('Repository Builds', () => {
|
||||
cy.get('[data-testid="Webhook Endpoint URL"').within(() => {
|
||||
cy.get('input').should(
|
||||
'have.value',
|
||||
'https://$token:faketoken@localhost:8080/webhooks/push/trigger/67595ac0-5014-4962-81a0-9a8d336ca851',
|
||||
`https://$token:faketoken@${Cypress.env(
|
||||
'REACT_QUAY_APP_API_URL',
|
||||
).replace(
|
||||
'http://',
|
||||
'',
|
||||
)}/webhooks/push/trigger/67595ac0-5014-4962-81a0-9a8d336ca851`,
|
||||
);
|
||||
});
|
||||
cy.contains('Done').click();
|
||||
@ -711,6 +716,248 @@ describe('Repository Builds', () => {
|
||||
cy.contains('Delete Trigger').click();
|
||||
cy.wait('@deleteTrigger');
|
||||
});
|
||||
|
||||
it('manually runs github trigger', () => {
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
|
||||
fixture: 'builds.json',
|
||||
}).as('getBuilds');
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
|
||||
fixture: 'build-triggers.json',
|
||||
}).as('getBuildTriggers');
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/githubfe-70b5-4bf9-8eb9-8dccf9874aed/fields/refs',
|
||||
{fixture: 'build-trigger-refs.json'},
|
||||
).as('getBuildTriggerRefs');
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/githubfe-70b5-4bf9-8eb9-8dccf9874aed/start',
|
||||
{statusCode: 200, body: {id: 'build001'}},
|
||||
).as('startBuild');
|
||||
cy.visit('/repository/testorg/testrepo?tab=builds');
|
||||
|
||||
const submitBuild = (cy) => {
|
||||
cy.get('#manually-start-build-modal').within(() => {
|
||||
cy.contains('button', 'Start Build').should('be.disabled');
|
||||
cy.contains('Manually Start Build Trigger');
|
||||
cy.contains('push to GitHub repository testgitorg/testgitrepo');
|
||||
cy.contains('Branch/Tag:');
|
||||
cy.get('button[aria-label="Menu toggle"]').click();
|
||||
cy.contains('master');
|
||||
cy.contains('development');
|
||||
cy.contains('1.0.0');
|
||||
cy.contains('1.0.1').click();
|
||||
cy.contains('button', 'Start Build').click();
|
||||
cy.get('@startBuild')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {refs: {kind: 'tag', name: '1.0.1'}});
|
||||
});
|
||||
cy.contains('Build started successfully with ID build001');
|
||||
};
|
||||
|
||||
// Test with build history button
|
||||
cy.contains('Start New Build').click();
|
||||
cy.get('#start-build-modal').within(() => {
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to GitHub repository testgitorg/testgitrepo',
|
||||
).within(() => {
|
||||
cy.contains('^newbranch$');
|
||||
cy.contains('Run Trigger Now').click();
|
||||
});
|
||||
});
|
||||
submitBuild(cy);
|
||||
|
||||
// Test run within row
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to GitHub repository testgitorg/testgitrepo',
|
||||
).within(() => {
|
||||
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
|
||||
cy.contains('Run Trigger').click();
|
||||
});
|
||||
submitBuild(cy);
|
||||
});
|
||||
|
||||
it('manually runs gitlab trigger', () => {
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
|
||||
fixture: 'builds.json',
|
||||
}).as('getBuilds');
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
|
||||
fixture: 'build-triggers.json',
|
||||
}).as('getBuildTriggers');
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/gitlab82-9fd5-4005-bc95-d3156855f0d5/fields/refs',
|
||||
{fixture: 'build-trigger-refs.json'},
|
||||
).as('getBuildTriggerRefs');
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/gitlab82-9fd5-4005-bc95-d3156855f0d5/start',
|
||||
{statusCode: 200, body: {id: 'build001'}},
|
||||
).as('startBuild');
|
||||
cy.visit('/repository/testorg/testrepo?tab=builds');
|
||||
|
||||
const submitBuild = (cy) => {
|
||||
cy.get('#manually-start-build-modal').within(() => {
|
||||
cy.contains('button', 'Start Build').should('be.disabled');
|
||||
cy.contains('Manually Start Build Trigger');
|
||||
cy.contains('push to GitLab repository testgitorg/testgitrepo');
|
||||
cy.contains('Branch/Tag:');
|
||||
cy.get('button[aria-label="Menu toggle"]').click();
|
||||
cy.contains('master');
|
||||
cy.contains('development');
|
||||
cy.contains('1.0.0');
|
||||
cy.contains('1.0.1').click();
|
||||
cy.contains('button', 'Start Build').click();
|
||||
cy.get('@startBuild')
|
||||
.its('request.body')
|
||||
.should('deep.equal', {refs: {kind: 'tag', name: '1.0.1'}});
|
||||
});
|
||||
cy.contains('Build started successfully with ID build001');
|
||||
};
|
||||
|
||||
// Test with build history button
|
||||
cy.contains('Start New Build').click();
|
||||
cy.get('#start-build-modal').within(() => {
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to GitLab repository testgitorg/testgitrepo',
|
||||
).within(() => {
|
||||
cy.contains('All');
|
||||
cy.contains('Run Trigger Now').click();
|
||||
});
|
||||
});
|
||||
submitBuild(cy);
|
||||
|
||||
// Test run within row
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to GitLab repository testgitorg/testgitrepo',
|
||||
).within(() => {
|
||||
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
|
||||
cy.contains('Run Trigger').click();
|
||||
});
|
||||
submitBuild(cy);
|
||||
});
|
||||
|
||||
it('manually runs custom trigger', () => {
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
|
||||
fixture: 'builds.json',
|
||||
}).as('getBuilds');
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
|
||||
fixture: 'build-triggers.json',
|
||||
}).as('getBuildTriggers');
|
||||
cy.intercept(
|
||||
'POST',
|
||||
'/api/v1/repository/testorg/testrepo/trigger/custom-git35014-4962-81a0-9a8d336ca851/start',
|
||||
{statusCode: 200, body: {id: 'build001'}},
|
||||
).as('startBuild');
|
||||
cy.visit('/repository/testorg/testrepo?tab=builds');
|
||||
|
||||
const submitBuild = (cy) => {
|
||||
cy.get('#manually-start-build-modal').within(() => {
|
||||
cy.contains('button', 'Start Build').should('be.disabled');
|
||||
cy.contains('Manually Start Build Trigger');
|
||||
cy.contains(
|
||||
'push to repository https://github.com/testgitorg/testgitrepo',
|
||||
);
|
||||
cy.contains('Commit:');
|
||||
cy.get('#manual-build-commit-input').type('invalidcommit');
|
||||
cy.contains('Invalid commit pattern');
|
||||
cy.get('#manual-build-commit-input').clear();
|
||||
cy.get('#manual-build-commit-input').type(
|
||||
'adadadf141dd4141a4ecbb3cb21282053a678203',
|
||||
);
|
||||
cy.contains('button', 'Start Build').click();
|
||||
cy.get('@startBuild').its('request.body').should('deep.equal', {
|
||||
commit_sha: 'adadadf141dd4141a4ecbb3cb21282053a678203',
|
||||
});
|
||||
});
|
||||
cy.contains('Build started successfully with ID build001');
|
||||
};
|
||||
|
||||
// Test with build history button
|
||||
cy.contains('Start New Build').click();
|
||||
cy.get('#start-build-modal').within(() => {
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to repository https://github.com/testgitorg/testgitrepo',
|
||||
).within(() => {
|
||||
cy.contains('All');
|
||||
cy.contains('Run Trigger Now').click();
|
||||
});
|
||||
});
|
||||
submitBuild(cy);
|
||||
|
||||
// Test run within row
|
||||
cy.contains(
|
||||
'tr',
|
||||
'push to repository https://github.com/testgitorg/testgitrepo',
|
||||
).within(() => {
|
||||
cy.get('button[data-testid="build-trigger-actions-kebab"]').click();
|
||||
cy.contains('Run Trigger').click();
|
||||
});
|
||||
submitBuild(cy);
|
||||
});
|
||||
|
||||
it('runs trigger with dockerfile', () => {
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/build/?limit=10', {
|
||||
fixture: 'builds.json',
|
||||
}).as('getBuilds');
|
||||
cy.intercept('GET', '/api/v1/repository/testorg/testrepo/trigger/', {
|
||||
fixture: 'build-triggers.json',
|
||||
}).as('getBuildTriggers');
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'/api/v1/repository/testorg/privaterepo?includeStats=false&includeTags=false',
|
||||
{statusCode: 200, body: {is_public: false}},
|
||||
).as('getRepoDetails');
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'/api/v1/organization/testorg/robots?permissions=true&token=false',
|
||||
{fixture: 'robots.json'},
|
||||
).as('getRobots');
|
||||
cy.intercept(
|
||||
'GET',
|
||||
'/api/v1/repository/testorg/testrepo/permissions/user/testorg+testrobot/transitive',
|
||||
{statusCode: 200, body: {permissions: [{role: 'read'}]}},
|
||||
).as('getTransitivePermissions');
|
||||
cy.intercept('POST', '/api/v1/filedrop/', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
url: `${Cypress.env(
|
||||
'REACT_QUAY_APP_API_URL',
|
||||
)}/userfiles/a1599e3f-aa56-4f90-8b1a-ec5f9e63ffe7`,
|
||||
file_id: 'a1599e3f-aa56-4f90-8b1a-ec5f9e63ffe7',
|
||||
},
|
||||
}).as('getFileDrop');
|
||||
cy.intercept('POST', '/api/v1/repository/testorg/testrepo/build/', {
|
||||
statusCode: 201,
|
||||
body: {id: 'build001'},
|
||||
}).as('startBuild');
|
||||
cy.visit('/repository/testorg/testrepo?tab=builds');
|
||||
|
||||
cy.contains('Start New Build').click();
|
||||
cy.get('#start-build-modal').within(() => {
|
||||
cy.contains('Upload Dockerfile').click();
|
||||
cy.fixture('TestDockerfile', null).as('dockerfile');
|
||||
cy.get('#dockerfile-upload').selectFile('@dockerfile', {
|
||||
action: 'drag-drop',
|
||||
});
|
||||
cy.contains(
|
||||
'The selected Dockerfile contains a FROM that refers to private repository testorg/privaterepo.',
|
||||
);
|
||||
cy.contains(
|
||||
'A robot account with read access to that repository is required for the build:',
|
||||
);
|
||||
cy.get('#repository-creator-dropdown').click();
|
||||
cy.contains('testorg+testrobot2');
|
||||
cy.contains('testorg+testrobot').click();
|
||||
cy.contains('button', 'Start Build').click();
|
||||
});
|
||||
cy.contains('Build started with ID build001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository Builds - Create Custom Git Build Triggers', () => {
|
||||
@ -996,7 +1243,9 @@ describe('Repository Builds - Create GitHub Build Triggers', () => {
|
||||
cy.contains('GitHub Repository Push').should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'https://github.com/login/oauth/authorize?client_id=testclientid&redirect_uri=http://localhost:8080/oauth2/github/callback/trigger/testorg/testrepo&scope=repo,user:email',
|
||||
`https://github.com/login/oauth/authorize?client_id=testclientid&redirect_uri=${Cypress.env(
|
||||
'REACT_QUAY_APP_API_URL',
|
||||
)}/oauth2/github/callback/trigger/testorg/testrepo&scope=repo,user:email`,
|
||||
);
|
||||
|
||||
cy.intercept(
|
||||
@ -1225,7 +1474,9 @@ describe('Repository Builds - Create GitHub Build Triggers', () => {
|
||||
cy.contains('GitLab Repository Push').should(
|
||||
'have.attr',
|
||||
'href',
|
||||
'https://gitlab.com/oauth/authorize?client_id=testclientid&redirect_uri=http://localhost:8080/oauth2/gitlab/callback/trigger&scope=api%20write_repository%20openid&response_type=code&state=repo:testorg/testrepo',
|
||||
`https://gitlab.com/oauth/authorize?client_id=testclientid&redirect_uri=${Cypress.env(
|
||||
'REACT_QUAY_APP_API_URL',
|
||||
)}/oauth2/gitlab/callback/trigger&scope=api%20write_repository%20openid&response_type=code&state=repo:testorg/testrepo`,
|
||||
);
|
||||
|
||||
cy.intercept(
|
||||
|
4
web/cypress/fixtures/TestDockerfile
Normal file
4
web/cypress/fixtures/TestDockerfile
Normal file
@ -0,0 +1,4 @@
|
||||
FROM localhost:8080/testorg/privaterepo
|
||||
LABEL org=Redhat
|
||||
LABEL version=1.0
|
||||
LABEL description="testimage"
|
@ -79,6 +79,32 @@
|
||||
"hook_id": 12345496
|
||||
},
|
||||
"can_invoke": true,
|
||||
"enabled": true,
|
||||
"disabled_reason": "user_toggled"
|
||||
},
|
||||
{
|
||||
"id": "disabled-9fd5-4005-bc95-d3156855f0d5",
|
||||
"service": "gitlab",
|
||||
"is_active": true,
|
||||
"build_source": "testgitorg/disabledrepo",
|
||||
"repository_url": "https://gitlab.com/testgitorg/disabledrepo",
|
||||
"config": {
|
||||
"build_source": "testgitorg/disabledrepo",
|
||||
"dockerfile_path": "/application/Dockerfile",
|
||||
"context": "/",
|
||||
"default_tag_from_ref": false,
|
||||
"latest_for_default_branch": false,
|
||||
"tag_templates": [],
|
||||
"credentials": [
|
||||
{
|
||||
"name": "SSH Public Key",
|
||||
"value": "fakekey"
|
||||
}
|
||||
],
|
||||
"key_id": 12349342,
|
||||
"hook_id": 12345496
|
||||
},
|
||||
"can_invoke": true,
|
||||
"enabled": false,
|
||||
"disabled_reason": "user_toggled"
|
||||
}
|
||||
|
30
web/cypress/fixtures/robots.json
Normal file
30
web/cypress/fixtures/robots.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"robots": [
|
||||
{
|
||||
"name": "testorg+testrobot",
|
||||
"created": "Tue, 29 Nov 2022 13:48:38 -0000",
|
||||
"last_accessed": null,
|
||||
"teams": [
|
||||
{
|
||||
"name": "chelsea",
|
||||
"avatar": {
|
||||
"name": "chelsea",
|
||||
"hash": "hash41c03ef78bd6daeaa6bb008896607a8413bf8ba6266be80327554b370a9e",
|
||||
"color": "#e7ba52",
|
||||
"kind": "team"
|
||||
}
|
||||
}
|
||||
],
|
||||
"repositories": ["testrepo"],
|
||||
"description": ""
|
||||
},
|
||||
{
|
||||
"name": "testorg+testrobot2",
|
||||
"created": "Wed, 30 Nov 2022 21:23:53 -0000",
|
||||
"last_accessed": null,
|
||||
"teams": [],
|
||||
"repositories": [],
|
||||
"description": ""
|
||||
}
|
||||
]
|
||||
}
|
56
web/src/components/FileUpload.tsx
Normal file
56
web/src/components/FileUpload.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import {FileUpload as PFFileUpload, DropEvent} from '@patternfly/react-core';
|
||||
import {ChangeEvent, useState} from 'react';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
|
||||
export default function FileUpload(props: FileUploadProps) {
|
||||
const [filename, setFilename] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleFileInputChange = (_, file: File) => {
|
||||
setFilename(file.name);
|
||||
};
|
||||
|
||||
const handleDataChange = (_event: DropEvent, value: string) => {
|
||||
props.onValueChange(value);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setFilename('');
|
||||
props.onValueChange('');
|
||||
if (!isNullOrUndefined(props.onClear)) {
|
||||
props.onClear();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileReadStarted = (_event: DropEvent, _fileHandle: File) => {
|
||||
setIsLoading(true);
|
||||
};
|
||||
|
||||
const handleFileReadFinished = (_event: DropEvent, _fileHandle: File) => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<PFFileUpload
|
||||
id={isNullOrUndefined(props.id) ? 'upload-file' : props.id}
|
||||
type="text"
|
||||
value={props.value}
|
||||
filename={filename}
|
||||
filenamePlaceholder="Drag and drop a file or upload one"
|
||||
onFileInputChange={handleFileInputChange}
|
||||
onDataChange={handleDataChange}
|
||||
onReadStarted={handleFileReadStarted}
|
||||
onReadFinished={handleFileReadFinished}
|
||||
onClearClick={handleClear}
|
||||
isLoading={isLoading}
|
||||
allowEditingUploadedText={false}
|
||||
browseButtonText="Upload"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface FileUploadProps {
|
||||
id?: string;
|
||||
value: string | File;
|
||||
onValueChange: (value: string | File) => void;
|
||||
onClear?: () => void;
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
export interface SearchState {
|
||||
query: string;
|
||||
field: string;
|
||||
isRegEx: boolean;
|
||||
isRegEx?: boolean;
|
||||
}
|
||||
|
||||
export interface OrgSearchState extends SearchState {
|
||||
|
@ -171,7 +171,6 @@ export function useGitSources(
|
||||
triggerUuid: string,
|
||||
gitNamespaceId: string,
|
||||
) {
|
||||
console.log(gitNamespaceId);
|
||||
const {data, isError, error, isLoading} = useQuery(
|
||||
['sources', org, repo, triggerUuid, gitNamespaceId],
|
||||
() => fetchSources(org, repo, triggerUuid, gitNamespaceId),
|
||||
@ -189,11 +188,13 @@ export function useSourceRefs(
|
||||
org: string,
|
||||
repo: string,
|
||||
triggerUuid: string,
|
||||
sourceRef: string,
|
||||
sourceRef?: string,
|
||||
enabled = true,
|
||||
) {
|
||||
const {data, isError, error, isLoading} = useQuery(
|
||||
['sourcerefs', org, repo, triggerUuid, sourceRef],
|
||||
() => fetchRefs(org, repo, triggerUuid, sourceRef),
|
||||
{enabled: enabled},
|
||||
);
|
||||
|
||||
return {
|
||||
|
@ -1,11 +1,13 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
import {
|
||||
FileDropResponse,
|
||||
SourceRef,
|
||||
fetchBuilds,
|
||||
fetchNamespaces,
|
||||
fetchRefs,
|
||||
fetchSources,
|
||||
fetchSubDirs,
|
||||
fileDrop,
|
||||
startBuild,
|
||||
startDockerfileBuild,
|
||||
uploadFile,
|
||||
} from 'src/resources/BuildResource';
|
||||
|
||||
export function useBuilds(
|
||||
@ -31,3 +33,58 @@ export function useBuilds(
|
||||
isLoading: isLoading,
|
||||
};
|
||||
}
|
||||
|
||||
export function useStartBuild(
|
||||
org: string,
|
||||
repo: string,
|
||||
triggerUuid: string,
|
||||
{onSuccess, onError},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const {mutate} = useMutation(
|
||||
async (ref: string | SourceRef) => startBuild(org, repo, triggerUuid, ref),
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(['repobuilds', org, repo]);
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: onError,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
startBuild: mutate,
|
||||
};
|
||||
}
|
||||
|
||||
export function useStartDockerfileBuild(
|
||||
org: string,
|
||||
repo: string,
|
||||
{onSuccess, onError},
|
||||
) {
|
||||
const queryClient = useQueryClient();
|
||||
const {mutate} = useMutation(
|
||||
async ({
|
||||
dockerfileContent,
|
||||
robot,
|
||||
}: {
|
||||
dockerfileContent: string;
|
||||
robot: string;
|
||||
}) => {
|
||||
const fileDropData: FileDropResponse = await fileDrop();
|
||||
await uploadFile(fileDropData.url, dockerfileContent);
|
||||
return await startDockerfileBuild(org, repo, fileDropData.file_id, robot);
|
||||
},
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries(['repobuilds', org, repo]);
|
||||
onSuccess(data);
|
||||
},
|
||||
onError: onError,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
startBuild: mutate,
|
||||
};
|
||||
}
|
||||
|
@ -1,13 +1,40 @@
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {fetchRepositoryDetails} from 'src/resources/RepositoryResource';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
import {
|
||||
fetchEntityTransitivePermission,
|
||||
fetchRepositoryDetails,
|
||||
} from 'src/resources/RepositoryResource';
|
||||
|
||||
export function useRepository(org: string, repo: string) {
|
||||
const {data, error} = useQuery(['repodetails', org, repo], () =>
|
||||
fetchRepositoryDetails(org, repo),
|
||||
export function useRepository(org: string, repo?: string) {
|
||||
const {data, error, isLoading, isError} = useQuery(
|
||||
['repodetails', org, repo],
|
||||
() => fetchRepositoryDetails(org, repo),
|
||||
{enabled: !isNullOrUndefined(repo)},
|
||||
);
|
||||
|
||||
return {
|
||||
repoDetails: data,
|
||||
errorLoadingRepoDetails: error,
|
||||
isLoading: isLoading,
|
||||
isError: isError,
|
||||
};
|
||||
}
|
||||
|
||||
export function useTransitivePermissions(
|
||||
org: string,
|
||||
repo: string,
|
||||
entity?: string,
|
||||
) {
|
||||
const {data, isLoading, isError, error} = useQuery(
|
||||
['transitivepermissions', org, repo, entity],
|
||||
() => fetchEntityTransitivePermission(org, repo, entity),
|
||||
{enabled: !isNullOrUndefined(entity)},
|
||||
);
|
||||
|
||||
return {
|
||||
permissions: data,
|
||||
isLoading: isLoading,
|
||||
isError: isError,
|
||||
error: error,
|
||||
};
|
||||
}
|
||||
|
@ -18,6 +18,7 @@ import {
|
||||
} from 'src/resources/RobotsResource';
|
||||
import {updateTeamForRobot} from 'src/resources/TeamResources';
|
||||
import {useOrganizations} from 'src/hooks/UseOrganizations';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
|
||||
export function useFetchRobotAccounts(
|
||||
orgName: string,
|
||||
@ -188,7 +189,7 @@ interface addDefaultPermsParams {
|
||||
robotDefaultPerm: string;
|
||||
}
|
||||
|
||||
export function useRobotAccounts({name, onSuccess, onError}) {
|
||||
export function useRobotAccounts({name, onSuccess, onError, enabled}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(20);
|
||||
const [namespace, setNamespace] = useState(name);
|
||||
@ -199,6 +200,7 @@ export function useRobotAccounts({name, onSuccess, onError}) {
|
||||
const {
|
||||
data: robotAccountsForOrg,
|
||||
isLoading: loading,
|
||||
isError,
|
||||
error,
|
||||
} = useQuery(
|
||||
['Namespace', namespace, 'robots'],
|
||||
@ -211,6 +213,7 @@ export function useRobotAccounts({name, onSuccess, onError}) {
|
||||
onError: (err) => {
|
||||
onError(err);
|
||||
},
|
||||
enabled: isNullOrUndefined(enabled) ? true : enabled,
|
||||
},
|
||||
);
|
||||
|
||||
@ -218,6 +221,7 @@ export function useRobotAccounts({name, onSuccess, onError}) {
|
||||
robotAccountsForOrg: robotAccountsForOrg,
|
||||
loading: loading,
|
||||
error,
|
||||
isError,
|
||||
setPage,
|
||||
setPerPage,
|
||||
page,
|
||||
|
59
web/src/libs/dockerfileParser.ts
Normal file
59
web/src/libs/dockerfileParser.ts
Normal file
@ -0,0 +1,59 @@
|
||||
export function getRegistryBaseImage(
|
||||
content: string,
|
||||
domain: string,
|
||||
): string | null {
|
||||
const baseImage = getBaseImage(content);
|
||||
if (!baseImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (baseImage.indexOf(`${domain}/`) != 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return baseImage.substring(domain.length + 1);
|
||||
}
|
||||
|
||||
function getBaseImage(content: string): string | null {
|
||||
const imageAndTag = getBaseImageAndTag(content);
|
||||
if (!imageAndTag) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Note, we have to handle a few different cases here:
|
||||
// 1) someimage
|
||||
// 2) someimage:tag
|
||||
// 3) host:port/someimage
|
||||
// 4) host:port/someimage:tag
|
||||
const lastIndex: number = imageAndTag.lastIndexOf(':');
|
||||
if (lastIndex == -1) {
|
||||
return imageAndTag;
|
||||
}
|
||||
|
||||
// Otherwise, check if there is a / in the portion after the split point. If so,
|
||||
// then the latter is part of the path (and not a tag).
|
||||
const afterColon: string = imageAndTag.substring(lastIndex + 1);
|
||||
if (afterColon.indexOf('/') != -1) {
|
||||
return imageAndTag;
|
||||
}
|
||||
|
||||
return imageAndTag.substring(0, lastIndex);
|
||||
}
|
||||
|
||||
function getBaseImageAndTag(content: string): string | null {
|
||||
let baseImageAndTag: string = null;
|
||||
|
||||
const fromIndex: number = content.indexOf('FROM ');
|
||||
if (fromIndex != -1) {
|
||||
let newlineIndex: number = content.indexOf('\n', fromIndex);
|
||||
if (newlineIndex == -1) {
|
||||
newlineIndex = content.length;
|
||||
}
|
||||
|
||||
baseImageAndTag = content
|
||||
.substring(fromIndex + 'FROM '.length, newlineIndex)
|
||||
.trim();
|
||||
}
|
||||
|
||||
return baseImageAndTag;
|
||||
}
|
@ -290,7 +290,6 @@ export async function fetchSources(
|
||||
triggerUuid: string,
|
||||
gitNamespaceId: string,
|
||||
) {
|
||||
console.log(gitNamespaceId);
|
||||
const response: AxiosResponse<FetchSourcesResponse> = await axios.post(
|
||||
`/api/v1/repository/${namespace}/${repo}/trigger/${triggerUuid}/sources`,
|
||||
{
|
||||
@ -313,13 +312,12 @@ export async function fetchRefs(
|
||||
namespace: string,
|
||||
repo: string,
|
||||
triggerUuid: string,
|
||||
source: string,
|
||||
source?: string,
|
||||
) {
|
||||
const body = isNullOrUndefined(source) ? {} : {build_source: source};
|
||||
const response: AxiosResponse<FetchRefsResponse> = await axios.post(
|
||||
`/api/v1/repository/${namespace}/${repo}/trigger/${triggerUuid}/fields/refs`,
|
||||
{
|
||||
build_source: source,
|
||||
},
|
||||
body,
|
||||
);
|
||||
return response.data?.values;
|
||||
}
|
||||
@ -346,3 +344,77 @@ export async function fetchSubDirs(
|
||||
response.data.contextMap = new Map(Object.entries(response.data.contextMap));
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function startBuild(
|
||||
org: string,
|
||||
repo: string,
|
||||
triggerUuid: string,
|
||||
ref: string | SourceRef,
|
||||
) {
|
||||
const body =
|
||||
typeof ref === 'string' || ref instanceof String
|
||||
? {commit_sha: ref}
|
||||
: {refs: ref};
|
||||
const response = await axios.post<RepositoryBuild>(
|
||||
`/api/v1/repository/${org}/${repo}/trigger/${triggerUuid}/start`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function startDockerfileBuild(
|
||||
org: string,
|
||||
repo: string,
|
||||
fileId: string,
|
||||
pull_robot?: string,
|
||||
) {
|
||||
const body: {file_id: string; pull_robot?: string} = {
|
||||
file_id: fileId,
|
||||
};
|
||||
if (!isNullOrUndefined(pull_robot)) {
|
||||
body.pull_robot = pull_robot;
|
||||
}
|
||||
const response = await axios.post<RepositoryBuild>(
|
||||
`/api/v1/repository/${org}/${repo}/build/`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export interface FileDropResponse {
|
||||
file_id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function fileDrop() {
|
||||
const response = await axios.post<FileDropResponse>('/api/v1/filedrop/', {
|
||||
mimeType: 'application/octet-stream',
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export function uploadFile(url: string, dockerfileContent: string) {
|
||||
// axios has trouble uploading files with binary content, so we use XMLHttpRequest instead
|
||||
return new Promise(function (resolve, reject) {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open('PUT', url, true);
|
||||
request.setRequestHeader('Content-Type', 'application/octet-stream');
|
||||
request.onload = function () {
|
||||
if (this.status >= 200 && this.status < 300) {
|
||||
resolve(request.response);
|
||||
} else {
|
||||
reject({
|
||||
status: this.status,
|
||||
statusText: request.statusText,
|
||||
});
|
||||
}
|
||||
};
|
||||
request.onerror = function () {
|
||||
reject({
|
||||
status: this.status,
|
||||
statusText: request.statusText,
|
||||
});
|
||||
};
|
||||
request.send(dockerfileContent);
|
||||
});
|
||||
}
|
||||
|
@ -309,3 +309,30 @@ export async function deleteRepository(ns: string, name: string) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface EntityTransitivePermission {
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface EntityTransitivePermissionResponse {
|
||||
permissions: EntityTransitivePermission[];
|
||||
}
|
||||
|
||||
export async function fetchEntityTransitivePermission(
|
||||
org: string,
|
||||
repo: string,
|
||||
entity: string,
|
||||
) {
|
||||
try {
|
||||
const response: AxiosResponse<EntityTransitivePermissionResponse> =
|
||||
await axios.get(
|
||||
`/api/v1/repository/${org}/${repo}/permissions/user/${entity}/transitive`,
|
||||
);
|
||||
return response.data.permissions;
|
||||
} catch (error) {
|
||||
// The backend returns a 404 if the entity exists but has no permissions
|
||||
if (error instanceof AxiosError && error.response?.status == 404) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -31,28 +31,26 @@ function Message({service}: {service: string}) {
|
||||
case 'custom-git':
|
||||
return (
|
||||
<Alert variant="info" title="Note:">
|
||||
<p>
|
||||
In order to use this trigger, the following first requires action:
|
||||
<List>
|
||||
<ListItem style={{marginTop: '0'}}>
|
||||
You must give the following public key read access to the git
|
||||
repository.
|
||||
</ListItem>
|
||||
<ListItem style={{marginTop: '0'}}>
|
||||
You must set your repository to POST to the following URL to
|
||||
trigger a build.
|
||||
</ListItem>
|
||||
</List>
|
||||
For more information, refer to the{' '}
|
||||
<a
|
||||
href="https://docs.projectquay.io/use_quay.html#setting-up-custom-git-trigger"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Custom Git Triggers documentation
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
In order to use this trigger, the following first requires action:
|
||||
<List>
|
||||
<ListItem style={{marginTop: '0'}}>
|
||||
You must give the following public key read access to the git
|
||||
repository.
|
||||
</ListItem>
|
||||
<ListItem style={{marginTop: '0'}}>
|
||||
You must set your repository to POST to the following URL to
|
||||
trigger a build.
|
||||
</ListItem>
|
||||
</List>
|
||||
For more information, refer to the{' '}
|
||||
<a
|
||||
href="https://docs.projectquay.io/use_quay.html#setting-up-custom-git-trigger"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Custom Git Triggers documentation
|
||||
</a>
|
||||
.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
@ -1,4 +1,12 @@
|
||||
import {Title, ToggleGroup, ToggleGroupItem} from '@patternfly/react-core';
|
||||
import {
|
||||
Button,
|
||||
Title,
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import {
|
||||
BanIcon,
|
||||
CheckCircleIcon,
|
||||
@ -17,7 +25,6 @@ import {LoadingPage} from 'src/components/LoadingPage';
|
||||
import Conditional from 'src/components/empty/Conditional';
|
||||
import RequestError from 'src/components/errors/RequestError';
|
||||
import {useBuilds} from 'src/hooks/UseBuilds';
|
||||
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
|
||||
import {
|
||||
formatDate,
|
||||
humanizeTimeForExpiry,
|
||||
@ -26,8 +33,13 @@ import {
|
||||
import {
|
||||
RepositoryBuild,
|
||||
RepositoryBuildPhase,
|
||||
RepositoryBuildTrigger,
|
||||
} from 'src/resources/BuildResource';
|
||||
import BuildTriggerDescription from './BuildTriggerDescription';
|
||||
import {RepositoryDetails} from 'src/resources/RepositoryResource';
|
||||
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
|
||||
import StartBuildModal from './BuildHistoryStartBuildModal';
|
||||
import ManuallyStartTrigger from './BuildHistoryManuallyStartTriggerModal';
|
||||
|
||||
const ONE_DAY_IN_SECONDS = 24 * 3600;
|
||||
const RESOURCE_URLS = {
|
||||
@ -54,10 +66,17 @@ enum Filters {
|
||||
}
|
||||
|
||||
export default function BuildHistory(props: BuildHistoryProps) {
|
||||
const config = useQuayConfig();
|
||||
const [buildsSinceInSeconds, setBuildsSinceInSeconds] = useState<number>();
|
||||
const [dateStartedFilter, setDateStartedFilter] = useState<Filters>(
|
||||
Filters.RECENT_BUILDS,
|
||||
);
|
||||
const [isRunBuildModalOpen, setIsRunBuildModalOpen] =
|
||||
useState<boolean>(false);
|
||||
const [isManuallyStartTriggerOpen, setIsManuallyStartTriggerOpen] =
|
||||
useState<boolean>(false);
|
||||
const [manualTrigger, setManualTrigger] =
|
||||
useState<RepositoryBuildTrigger>(null);
|
||||
const {builds, isError, error, isLoading} = useBuilds(
|
||||
props.org,
|
||||
props.repo,
|
||||
@ -94,9 +113,26 @@ export default function BuildHistory(props: BuildHistoryProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title headingLevel="h2" style={{paddingLeft: '1em', paddingTop: '1em'}}>
|
||||
Build History
|
||||
</Title>
|
||||
<Toolbar>
|
||||
<ToolbarContent style={{paddingLeft: '1em', paddingTop: '1em'}}>
|
||||
<ToolbarItem>
|
||||
<Title headingLevel="h2">Build History</Title>
|
||||
</ToolbarItem>
|
||||
<Conditional
|
||||
if={
|
||||
props.repoDetails.can_write &&
|
||||
config?.features.BUILD_SUPPORT &&
|
||||
config?.config?.REGISTRY_STATE !== 'readonly'
|
||||
}
|
||||
>
|
||||
<ToolbarItem align={{default: 'alignRight'}}>
|
||||
<Button onClick={() => setIsRunBuildModalOpen(true)}>
|
||||
Start New Build
|
||||
</Button>
|
||||
</ToolbarItem>
|
||||
</Conditional>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<ToggleGroup
|
||||
aria-label="filter build list by date"
|
||||
style={{padding: '1em'}}
|
||||
@ -163,6 +199,29 @@ export default function BuildHistory(props: BuildHistoryProps) {
|
||||
</Tbody>
|
||||
</Table>
|
||||
</Conditional>
|
||||
<StartBuildModal
|
||||
org={props.org}
|
||||
repo={props.repo}
|
||||
isOpen={isRunBuildModalOpen}
|
||||
onClose={() => setIsRunBuildModalOpen(false)}
|
||||
triggers={props.triggers}
|
||||
onSelectTrigger={(trigger) => {
|
||||
setIsRunBuildModalOpen(false);
|
||||
setManualTrigger(trigger);
|
||||
setIsManuallyStartTriggerOpen(true);
|
||||
}}
|
||||
/>
|
||||
<Conditional
|
||||
if={isManuallyStartTriggerOpen && !isNullOrUndefined(manualTrigger)}
|
||||
>
|
||||
<ManuallyStartTrigger
|
||||
org={props.org}
|
||||
repo={props.repo}
|
||||
trigger={manualTrigger}
|
||||
isOpen={isManuallyStartTriggerOpen}
|
||||
onClose={() => setIsManuallyStartTriggerOpen(false)}
|
||||
/>
|
||||
</Conditional>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -451,4 +510,6 @@ function getLongDescription(message: string) {
|
||||
interface BuildHistoryProps {
|
||||
org: string;
|
||||
repo: string;
|
||||
repoDetails: RepositoryDetails;
|
||||
triggers: RepositoryBuildTrigger[];
|
||||
}
|
||||
|
@ -0,0 +1,157 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
HelperText,
|
||||
HelperTextItem,
|
||||
Modal,
|
||||
ModalVariant,
|
||||
Spinner,
|
||||
TextInput,
|
||||
Title,
|
||||
} from '@patternfly/react-core';
|
||||
import {RepositoryBuildTrigger, SourceRef} from 'src/resources/BuildResource';
|
||||
import BuildTriggerDescription from './BuildTriggerDescription';
|
||||
import Conditional from 'src/components/empty/Conditional';
|
||||
import {useState} from 'react';
|
||||
import {useStartBuild} from 'src/hooks/UseBuilds';
|
||||
import {useAlerts} from 'src/hooks/UseAlerts';
|
||||
import {AlertVariant} from 'src/atoms/AlertState';
|
||||
import {useSourceRefs} from 'src/hooks/UseBuildTriggers';
|
||||
import TypeAheadSelect from 'src/components/TypeAheadSelect';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
|
||||
export default function ManuallyStartTrigger(props: ManuallyStartTriggerProps) {
|
||||
const {trigger, org, repo} = props;
|
||||
const [commit, setCommit] = useState<string>('');
|
||||
const [ref, setRef] = useState<SourceRef>({name: '', kind: null});
|
||||
const [isValid, setIsValid] = useState<boolean>(false);
|
||||
const {addAlert} = useAlerts();
|
||||
const {refs, isLoading, isError, error} = useSourceRefs(
|
||||
org,
|
||||
repo,
|
||||
trigger?.id,
|
||||
null,
|
||||
trigger.service !== 'custom-git',
|
||||
);
|
||||
const {startBuild} = useStartBuild(org, repo, trigger?.id, {
|
||||
onSuccess: (data) => {
|
||||
addAlert({
|
||||
title: `Build started successfully with ID ${data.id}`,
|
||||
variant: AlertVariant.Success,
|
||||
});
|
||||
props.onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
addAlert({
|
||||
title: 'Failed to start build',
|
||||
variant: AlertVariant.Failure,
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onChange = (value: string) => {
|
||||
setIsValid(/^[A-Fa-f0-9]{7,}$/.test(value));
|
||||
setCommit(value);
|
||||
};
|
||||
|
||||
const onRefChange = (value: string) => {
|
||||
const foundRef = refs?.find((ref) => ref.name === value);
|
||||
if (isNullOrUndefined(foundRef)) {
|
||||
setIsValid(false);
|
||||
setRef({name: value, kind: null});
|
||||
} else {
|
||||
setIsValid(true);
|
||||
setRef(foundRef);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
id="manually-start-build-modal"
|
||||
isOpen={props.isOpen}
|
||||
aria-label="Manually Start Build"
|
||||
onClose={() => props.onClose()}
|
||||
variant={ModalVariant.medium}
|
||||
actions={[
|
||||
<Button
|
||||
key="start-build"
|
||||
isDisabled={!isValid}
|
||||
onClick={() =>
|
||||
startBuild(trigger?.service === 'custom-git' ? commit : ref)
|
||||
}
|
||||
>
|
||||
Start Build
|
||||
</Button>,
|
||||
<Button key="cancel" variant="primary" onClick={() => props.onClose()}>
|
||||
Cancel
|
||||
</Button>,
|
||||
]}
|
||||
style={{
|
||||
overflowX: 'visible',
|
||||
overflowY: 'visible',
|
||||
}}
|
||||
>
|
||||
<Conditional if={trigger?.service === 'custom-git'}>
|
||||
<Title headingLevel="h4">Manually Start Build Trigger</Title>
|
||||
<BuildTriggerDescription trigger={trigger} />
|
||||
<br />
|
||||
Commit:
|
||||
<TextInput
|
||||
id="manual-build-commit-input"
|
||||
value={commit}
|
||||
onChange={(_, value) => onChange(value)}
|
||||
type="text"
|
||||
pattern="^([A-Fa-f0-9]{7,})$"
|
||||
placeholder="1c002dd"
|
||||
/>
|
||||
<Conditional if={!isValid && commit !== ''}>
|
||||
<HelperText id="helper-text">
|
||||
<HelperTextItem variant="error">
|
||||
Invalid commit pattern
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
</Conditional>
|
||||
</Conditional>
|
||||
<Conditional if={trigger?.service !== 'custom-git'}>
|
||||
<Conditional if={isLoading}>
|
||||
<Spinner />
|
||||
</Conditional>
|
||||
<Conditional if={isError}>
|
||||
<Alert variant="danger" title={error?.toString()} />
|
||||
</Conditional>
|
||||
<Conditional if={!isLoading && !isError}>
|
||||
<Title headingLevel="h4">Manually Start Build Trigger</Title>
|
||||
<BuildTriggerDescription trigger={trigger} />
|
||||
<br />
|
||||
Branch/Tag:{' '}
|
||||
<TypeAheadSelect
|
||||
value={ref.name}
|
||||
onChange={(value) => onRefChange(value)}
|
||||
initialSelectOptions={refs?.map((ref, index) => ({
|
||||
key: `${ref.kind}/${ref.name}`,
|
||||
onClick: () => setRef(ref),
|
||||
id: `ref-option-${index}`,
|
||||
value: ref.name,
|
||||
}))}
|
||||
/>
|
||||
<Conditional if={!isValid && ref?.name !== ''}>
|
||||
<HelperText id="helper-text">
|
||||
<HelperTextItem variant="error">
|
||||
Branch/Tag not found
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
</Conditional>
|
||||
</Conditional>
|
||||
</Conditional>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface ManuallyStartTriggerProps {
|
||||
org: string;
|
||||
repo: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
trigger: RepositoryBuildTrigger;
|
||||
}
|
@ -0,0 +1,123 @@
|
||||
import {
|
||||
Modal,
|
||||
ModalVariant,
|
||||
Button,
|
||||
Title,
|
||||
Tabs,
|
||||
Tab,
|
||||
TabTitleText,
|
||||
Tooltip,
|
||||
} from '@patternfly/react-core';
|
||||
import {Table, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
|
||||
import {useState} from 'react';
|
||||
import {RepositoryBuildTrigger} from 'src/resources/BuildResource';
|
||||
import BuildTriggerDescription from './BuildTriggerDescription';
|
||||
import Conditional from 'src/components/empty/Conditional';
|
||||
import DockerfileUploadBuild from './BuildHistoryStartBuildModalUploadDockerfile';
|
||||
|
||||
export default function StartBuildModal(props: StartNewBuildModalProps) {
|
||||
const [activeTabKey, setActiveTabKey] = useState<string | number>(0);
|
||||
const activteTriggers = props.triggers.filter((trigger) => trigger.is_active);
|
||||
return (
|
||||
<Modal
|
||||
id="start-build-modal"
|
||||
aria-label="Start a new build modal"
|
||||
isOpen={props.isOpen}
|
||||
onClose={() => props.onClose()}
|
||||
variant={ModalVariant.medium}
|
||||
style={{
|
||||
overflowX: 'visible',
|
||||
overflowY: 'visible',
|
||||
}}
|
||||
>
|
||||
<Title headingLevel="h4">Start Repository Build</Title>
|
||||
<Tabs
|
||||
activeKey={activeTabKey}
|
||||
onSelect={(_, eventKey) => setActiveTabKey(eventKey)}
|
||||
aria-label="Start a new build tabs"
|
||||
role="region"
|
||||
>
|
||||
<Tab
|
||||
eventKey={0}
|
||||
title={<TabTitleText>Invoke Build Trigger</TabTitleText>}
|
||||
aria-label="invoke build trigger tab"
|
||||
>
|
||||
<p>
|
||||
Manually running a build trigger provides the means for invoking a
|
||||
build trigger as-if called from the underlying service for the
|
||||
latest commit to a particular branch or tag.
|
||||
</p>
|
||||
<Table variant="compact">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Trigger Description</Th>
|
||||
<Th>Branches/Tags</Th>
|
||||
<Th></Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
<Conditional if={activteTriggers?.length === 0}>
|
||||
<Tr>
|
||||
<Td colSpan={3}>
|
||||
<p>No build triggers available for this repository.</p>
|
||||
</Td>
|
||||
</Tr>
|
||||
</Conditional>
|
||||
{activteTriggers.map((trigger) => (
|
||||
<Tr key={trigger.id}>
|
||||
<Td>
|
||||
<BuildTriggerDescription trigger={trigger} />
|
||||
</Td>
|
||||
<Td>{trigger?.config?.branchtag_regex || 'All'}</Td>
|
||||
<Td>
|
||||
<Conditional if={trigger.can_invoke && trigger.enabled}>
|
||||
<a onClick={() => props.onSelectTrigger(trigger)}>
|
||||
Run Trigger Now
|
||||
</a>
|
||||
</Conditional>
|
||||
<Conditional if={!trigger.can_invoke}>
|
||||
<Tooltip content="You do not have permission to run this trigger">
|
||||
<span>No permission to run</span>
|
||||
</Tooltip>
|
||||
</Conditional>
|
||||
<Conditional if={!trigger.enabled}>
|
||||
<span>Trigger Disabled</span>
|
||||
</Conditional>
|
||||
</Td>
|
||||
</Tr>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
<br />
|
||||
<Button
|
||||
key="cancel"
|
||||
variant="primary"
|
||||
onClick={() => props.onClose()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey={1}
|
||||
title={<TabTitleText>Upload Dockerfile</TabTitleText>}
|
||||
aria-label="upload dockerfile tab"
|
||||
>
|
||||
<DockerfileUploadBuild
|
||||
org={props.org}
|
||||
repo={props.repo}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
interface StartNewBuildModalProps {
|
||||
org: string;
|
||||
repo: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
triggers: RepositoryBuildTrigger[];
|
||||
onSelectTrigger: (trigger: RepositoryBuildTrigger) => void;
|
||||
}
|
@ -0,0 +1,270 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
SelectGroup,
|
||||
SelectOption,
|
||||
Spinner,
|
||||
AlertVariant as PFAlertVariant,
|
||||
HelperText,
|
||||
HelperTextItem,
|
||||
} from '@patternfly/react-core';
|
||||
import {DesktopIcon} from '@patternfly/react-icons';
|
||||
import React from 'react';
|
||||
import {useState} from 'react';
|
||||
import {AlertVariant} from 'src/atoms/AlertState';
|
||||
import EntitySearch from 'src/components/EntitySearch';
|
||||
import FileUpload from 'src/components/FileUpload';
|
||||
import Conditional from 'src/components/empty/Conditional';
|
||||
import CreateRobotAccountModal from 'src/components/modals/CreateRobotAccountModal';
|
||||
import {useAlerts} from 'src/hooks/UseAlerts';
|
||||
import {useStartDockerfileBuild} from 'src/hooks/UseBuilds';
|
||||
import {useQuayConfig} from 'src/hooks/UseQuayConfig';
|
||||
import {useRepository, useTransitivePermissions} from 'src/hooks/UseRepository';
|
||||
import {useFetchTeams} from 'src/hooks/UseTeams';
|
||||
import {useRobotAccounts} from 'src/hooks/useRobotAccounts';
|
||||
import {getRegistryBaseImage} from 'src/libs/dockerfileParser';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
import {RepositoryBuild} from 'src/resources/BuildResource';
|
||||
import {Entity} from 'src/resources/UserResource';
|
||||
import {RepoPermissionDropdownItems} from 'src/routes/RepositoriesList/RobotAccountsList';
|
||||
|
||||
export default function DockerfileUploadBuild(
|
||||
props: DockerfileUploadBuildProps,
|
||||
) {
|
||||
const [rejected, setRejected] = useState<boolean>(false);
|
||||
const [value, setValue] = useState('');
|
||||
const [privateRepo, setPrivatRepo] = useState<string>();
|
||||
const [selectedRobot, setSelectedRobot] = useState<string>(null);
|
||||
const [isCreateRobotModalOpen, setIsCreateRobotModalOpen] = useState(false);
|
||||
const {addAlert} = useAlerts();
|
||||
const {teams} = useFetchTeams(props.org);
|
||||
const [org, repo] = privateRepo?.split('/') ?? [null, null];
|
||||
const {repoDetails} = useRepository(org, repo);
|
||||
const {
|
||||
permissions,
|
||||
isLoading: isLoadingTransitivePermissions,
|
||||
isError: isErrorLoadingTransitivePermissions,
|
||||
error: errorLoadingTransitivePermissions,
|
||||
} = useTransitivePermissions(props.org, props.repo, selectedRobot);
|
||||
const {
|
||||
robotAccountsForOrg: robots,
|
||||
loading,
|
||||
isError,
|
||||
error,
|
||||
} = useRobotAccounts({
|
||||
name: props.org,
|
||||
onSuccess: () => null,
|
||||
onError: () => null,
|
||||
enabled: !isNullOrUndefined(privateRepo),
|
||||
});
|
||||
const {startBuild} = useStartDockerfileBuild(props.org, props.repo, {
|
||||
onSuccess: (data: RepositoryBuild) => {
|
||||
addAlert({
|
||||
variant: AlertVariant.Success,
|
||||
title: `Build started with ID ${data.id}`,
|
||||
});
|
||||
props.onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: `Failed to start build`,
|
||||
});
|
||||
},
|
||||
});
|
||||
const config = useQuayConfig();
|
||||
|
||||
if (isErrorLoadingTransitivePermissions) {
|
||||
return (
|
||||
<Alert
|
||||
variant={PFAlertVariant.danger}
|
||||
title="Failed to verify robot account permissions"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
variant={PFAlertVariant.danger}
|
||||
title="Failed to load robot accounts for private base image"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const onFileUpload = (value: string) => {
|
||||
if (value.includes('FROM')) {
|
||||
const baseImage = getRegistryBaseImage(
|
||||
value,
|
||||
config?.config?.SERVER_HOSTNAME,
|
||||
);
|
||||
if (!isNullOrUndefined(baseImage)) {
|
||||
setPrivatRepo(baseImage);
|
||||
}
|
||||
setValue(value);
|
||||
setRejected(false);
|
||||
} else {
|
||||
setRejected(true);
|
||||
setValue('');
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<FileUpload
|
||||
id="dockerfile-upload"
|
||||
value={value}
|
||||
onValueChange={onFileUpload}
|
||||
onClear={() => {
|
||||
setValue('');
|
||||
setPrivatRepo(null);
|
||||
setSelectedRobot(null);
|
||||
setRejected(false);
|
||||
}}
|
||||
/>
|
||||
<Conditional if={rejected}>
|
||||
<HelperText>
|
||||
<HelperTextItem variant={rejected ? 'error' : 'default'}>
|
||||
Invalid Dockerfile format
|
||||
</HelperTextItem>
|
||||
</HelperText>
|
||||
</Conditional>
|
||||
<p>Please select a Dockerfile</p>
|
||||
<Conditional
|
||||
if={
|
||||
!isNullOrUndefined(privateRepo) &&
|
||||
!isNullOrUndefined(repoDetails) &&
|
||||
!repoDetails.is_public
|
||||
}
|
||||
>
|
||||
<br />
|
||||
<Alert
|
||||
variant={PFAlertVariant.warning}
|
||||
title={
|
||||
<>
|
||||
<p>
|
||||
The selected Dockerfile contains a <code>FROM</code> that refers
|
||||
to private repository <strong>{privateRepo}</strong>.
|
||||
</p>
|
||||
<p>
|
||||
A robot account with read access to that repository is required
|
||||
for the build:
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<br />
|
||||
<EntitySearch
|
||||
id="repository-creator-dropdown"
|
||||
org={props.org}
|
||||
includeTeams={false}
|
||||
onSelect={(e: Entity) => {
|
||||
setSelectedRobot(e.name);
|
||||
}}
|
||||
onClear={() => setSelectedRobot(null)}
|
||||
value={selectedRobot}
|
||||
defaultOptions={
|
||||
<React.Fragment key="creator">
|
||||
<SelectGroup label="Robot accounts" key="robot-account-grp">
|
||||
{loading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
robots?.map(({name}) => (
|
||||
<SelectOption
|
||||
data-testid={`${name}-robot-accnt`}
|
||||
key={name}
|
||||
value={name}
|
||||
onClick={() => {
|
||||
setSelectedRobot(name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</SelectOption>
|
||||
))
|
||||
)}
|
||||
</SelectGroup>
|
||||
<Divider component="li" key={7} />
|
||||
<SelectOption
|
||||
data-testid="create-new-robot-accnt-btn"
|
||||
key="create-robot-account"
|
||||
component="button"
|
||||
onClick={() =>
|
||||
setIsCreateRobotModalOpen(!isCreateRobotModalOpen)
|
||||
}
|
||||
isFocused
|
||||
>
|
||||
<DesktopIcon /> Create robot account
|
||||
</SelectOption>
|
||||
</React.Fragment>
|
||||
}
|
||||
placeholderText="Add a registered user, robot to team"
|
||||
/>
|
||||
<Conditional
|
||||
if={
|
||||
!isNullOrUndefined(selectedRobot) &&
|
||||
permissions?.length === 0 &&
|
||||
!isLoadingTransitivePermissions
|
||||
}
|
||||
>
|
||||
<br />
|
||||
<Alert
|
||||
variant={PFAlertVariant.warning}
|
||||
title={
|
||||
<>
|
||||
Robot account <strong>{selectedRobot}</strong> does not have
|
||||
read permission on repository <strong>{privateRepo}</strong>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Conditional>
|
||||
</Conditional>
|
||||
<br />
|
||||
<br />
|
||||
<Button
|
||||
isDisabled={
|
||||
isNullOrUndefined(value) ||
|
||||
value === '' ||
|
||||
(isNullOrUndefined(selectedRobot) &&
|
||||
!isNullOrUndefined(privateRepo) &&
|
||||
!isNullOrUndefined(repoDetails) &&
|
||||
!repoDetails.is_public)
|
||||
}
|
||||
onClick={() =>
|
||||
startBuild({dockerfileContent: value, robot: selectedRobot})
|
||||
}
|
||||
>
|
||||
Start Build
|
||||
</Button>{' '}
|
||||
<Button onClick={() => props.onClose()}>Close</Button>
|
||||
<Conditional if={isCreateRobotModalOpen}>
|
||||
<CreateRobotAccountModal
|
||||
isModalOpen={isCreateRobotModalOpen}
|
||||
handleModalToggle={() =>
|
||||
setIsCreateRobotModalOpen(!isCreateRobotModalOpen)
|
||||
}
|
||||
orgName={props.org}
|
||||
teams={teams}
|
||||
RepoPermissionDropdownItems={RepoPermissionDropdownItems}
|
||||
setEntity={(entity: Entity) => setSelectedRobot(entity.name)}
|
||||
showSuccessAlert={(message) =>
|
||||
addAlert({
|
||||
variant: AlertVariant.Success,
|
||||
title: message,
|
||||
})
|
||||
}
|
||||
showErrorAlert={(message) =>
|
||||
addAlert({
|
||||
variant: AlertVariant.Failure,
|
||||
title: message,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Conditional>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface DockerfileUploadBuildProps {
|
||||
org: string;
|
||||
repo: string;
|
||||
onClose: () => void;
|
||||
}
|
@ -11,6 +11,10 @@ import BuildTriggerToggleModal from './BuildTriggerToggleModal';
|
||||
import BuildTriggerViewCredentialsModal from './BuildTriggerViewCredentialsModal';
|
||||
import {RepositoryBuildTrigger} from 'src/resources/BuildResource';
|
||||
import BuildTriggerDeleteModal from './BuildTriggerDeleteModal';
|
||||
import {triggerAsyncId} from 'async_hooks';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
import Conditional from 'src/components/empty/Conditional';
|
||||
import ManuallyStartTrigger from './BuildHistoryManuallyStartTriggerModal';
|
||||
|
||||
export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@ -20,6 +24,8 @@ export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
|
||||
useState(false);
|
||||
const [isDeleteTriggerModalOpen, setIsDeleteTriggerModalOpen] =
|
||||
useState(false);
|
||||
const [isManuallyStartTriggerOpen, setIsManuallyStartTriggerOpen] =
|
||||
useState(false);
|
||||
|
||||
const dropdownItems = [
|
||||
<DropdownItem
|
||||
@ -31,6 +37,16 @@ export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
|
||||
>
|
||||
View Credentials
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
key="run-trigger-action"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
setIsManuallyStartTriggerOpen(true);
|
||||
}}
|
||||
isDisabled={!props.trigger?.enabled}
|
||||
>
|
||||
Run Trigger Now
|
||||
</DropdownItem>,
|
||||
<DropdownItem
|
||||
key="toggle-trigger-action"
|
||||
onClick={() => {
|
||||
@ -95,6 +111,17 @@ export default function BuildTriggerActions(props: BuildTriggerActionsProps) {
|
||||
isOpen={isDeleteTriggerModalOpen}
|
||||
onClose={() => setIsDeleteTriggerModalOpen(false)}
|
||||
/>
|
||||
<Conditional
|
||||
if={isManuallyStartTriggerOpen && !isNullOrUndefined(props.trigger)}
|
||||
>
|
||||
<ManuallyStartTrigger
|
||||
org={props.org}
|
||||
repo={props.repo}
|
||||
trigger={props.trigger}
|
||||
isOpen={isManuallyStartTriggerOpen}
|
||||
onClose={() => setIsManuallyStartTriggerOpen(false)}
|
||||
/>
|
||||
</Conditional>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -7,9 +7,6 @@ import {
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import {Table, Tbody, Td, Th, Thead, Tr} from '@patternfly/react-table';
|
||||
import {LoadingPage} from 'src/components/LoadingPage';
|
||||
import RequestError from 'src/components/errors/RequestError';
|
||||
import {useBuildTriggers} from 'src/hooks/UseBuildTriggers';
|
||||
import BuildTriggerDescription from './BuildTriggerDescription';
|
||||
import Conditional from 'src/components/empty/Conditional';
|
||||
import {isNullOrUndefined} from 'src/libs/utils';
|
||||
@ -23,34 +20,23 @@ import InactiveTrigger from './BuildTriggersInactiveTriggerRow';
|
||||
import CreateBuildTriggerDropdown from './BuildCreateTriggerDropdown';
|
||||
import SetupBuildTriggerModal from './BuildTriggerSetupModal';
|
||||
import {RepositoryDetails} from 'src/resources/RepositoryResource';
|
||||
import {RepositoryBuildTrigger} from 'src/resources/BuildResource';
|
||||
|
||||
export default function BuildTriggers(props: BuildTriggersProps) {
|
||||
const config = useQuayConfig();
|
||||
const [isSetupTriggerOpen, setIsSetupTriggerOpen] = useState(
|
||||
!isNullOrUndefined(props.setupTriggerUuid),
|
||||
);
|
||||
const {triggers, isLoading, isError, error} = useBuildTriggers(
|
||||
props.org,
|
||||
props.repo,
|
||||
);
|
||||
const [triggerToggleOptions, setTriggerToggleOptions] = useState<{
|
||||
trigger_uuid: string;
|
||||
enabled: boolean;
|
||||
isOpen: boolean;
|
||||
}>({trigger_uuid: null, enabled: null, isOpen: false});
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <RequestError message={error as string} />;
|
||||
}
|
||||
|
||||
const activeTriggers = triggers.filter(
|
||||
const activeTriggers = props.triggers.filter(
|
||||
(trigger) => trigger.is_active === true,
|
||||
);
|
||||
const inActiveTriggers = triggers.filter(
|
||||
const inActiveTriggers = props.triggers.filter(
|
||||
(trigger) => trigger.is_active == false,
|
||||
);
|
||||
|
||||
@ -59,7 +45,7 @@ export default function BuildTriggers(props: BuildTriggersProps) {
|
||||
<Toolbar>
|
||||
<ToolbarContent style={{paddingLeft: '1em', paddingTop: '1em'}}>
|
||||
<ToolbarItem>
|
||||
<Title headingLevel="h2">Build triggers</Title>
|
||||
<Title headingLevel="h2">Build Triggers</Title>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem align={{default: 'alignRight'}}>
|
||||
<CreateBuildTriggerDropdown
|
||||
@ -69,7 +55,7 @@ export default function BuildTriggers(props: BuildTriggersProps) {
|
||||
</ToolbarItem>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<Conditional if={triggers.length > 0}>
|
||||
<Conditional if={props.triggers.length > 0}>
|
||||
<Table aria-label="Repository build triggers table" variant="compact">
|
||||
<Thead>
|
||||
<Tr>
|
||||
@ -203,7 +189,7 @@ export default function BuildTriggers(props: BuildTriggersProps) {
|
||||
))}
|
||||
</Table>
|
||||
</Conditional>
|
||||
<Conditional if={triggers.length === 0}>
|
||||
<Conditional if={props.triggers.length === 0}>
|
||||
<p style={{padding: '1em'}}>
|
||||
No build triggers defined. Build triggers invoke builds whenever the
|
||||
triggered condition is met (source control push, webhook, etc)
|
||||
@ -240,4 +226,5 @@ interface BuildTriggersProps {
|
||||
repo: string;
|
||||
setupTriggerUuid?: string;
|
||||
repoDetails: RepositoryDetails;
|
||||
triggers: RepositoryBuildTrigger[];
|
||||
}
|
||||
|
@ -2,18 +2,40 @@ import {Card, PageSection} from '@patternfly/react-core';
|
||||
import BuildHistory from './BuildHistory';
|
||||
import BuildTriggers from './BuildTriggers';
|
||||
import {RepositoryDetails} from 'src/resources/RepositoryResource';
|
||||
import {useBuildTriggers} from 'src/hooks/UseBuildTriggers';
|
||||
import {LoadingPage} from 'src/components/LoadingPage';
|
||||
import RequestError from 'src/components/errors/RequestError';
|
||||
|
||||
export default function Builds(props: BuildsProps) {
|
||||
const {triggers, isLoading, isError, error} = useBuildTriggers(
|
||||
props.org,
|
||||
props.repo,
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingPage />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <RequestError message={error.toString()} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<BuildHistory org={props.org} repo={props.repo} />
|
||||
<BuildHistory
|
||||
org={props.org}
|
||||
repo={props.repo}
|
||||
repoDetails={props.repoDetails}
|
||||
triggers={triggers}
|
||||
/>
|
||||
</Card>
|
||||
<br />
|
||||
<Card>
|
||||
<BuildTriggers
|
||||
org={props.org}
|
||||
repo={props.repo}
|
||||
triggers={triggers}
|
||||
setupTriggerUuid={props.setupTriggerUuid}
|
||||
repoDetails={props.repoDetails}
|
||||
/>
|
||||
|
Loading…
x
Reference in New Issue
Block a user