diff --git a/packages/utils/src/utils/router.js b/packages/utils/src/utils/router.js index 7a6abd4f64..cafde225c6 100644 --- a/packages/utils/src/utils/router.js +++ b/packages/utils/src/utils/router.js @@ -51,6 +51,9 @@ export const paths = { }, byNamespace() { return byNamespace({ path: '/customruns' }); + }, + create() { + return '/customruns/create'; } }, eventListeners: { diff --git a/src/api/customRuns.js b/src/api/customRuns.js index f9f8a94056..9b0f79d4ac 100644 --- a/src/api/customRuns.js +++ b/src/api/customRuns.js @@ -18,6 +18,9 @@ import { deleteRequest, get, patch, post } from './comms'; import { getQueryParams, getTektonAPI, + getTektonPipelinesAPIVersion, + removeSystemAnnotations, + removeSystemLabels, useCollection, useResource } from './utils'; @@ -39,6 +42,53 @@ function getCustomRunsAPI({ filters, isWebSocket, name, namespace }) { ); } +export function getCustomRunPayload({ + kind, + labels, + namespace, + nodeSelector, + params, + serviceAccount, + customName, + customRunName = `${customName ? `${customName}-run` : 'run'}-${Date.now()}`, + timeout +}) { + const payload = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'CustomRun', + metadata: { + name: customRunName, + namespace + }, + spec: { + customRef: { + apiVersion: '', + kind: 'Custom' + } + } + }; + if (labels) { + payload.metadata.labels = labels; + } + if (params) { + payload.spec.params = Object.keys(params).map(name => ({ + name, + value: params[name] + })); + } + if (nodeSelector) { + payload.spec.podTemplate = { nodeSelector }; + } + if (serviceAccount) { + payload.spec.serviceAccountName = serviceAccount; + } + if (timeout) { + payload.spec.timeout = timeout; + } + + return payload; +} + export function getCustomRuns({ filters = [], namespace } = {}) { const uri = getCustomRunsAPI({ filters, namespace }); return get(uri); @@ -55,6 +105,7 @@ export function getCustomRun({ name, namespace }) { export function useCustomRuns(params) { const webSocketURL = getCustomRunsAPI({ ...params, isWebSocket: true }); + console.log("url: " + webSocketURL); return useCollection({ api: getCustomRuns, kind: 'CustomRun', @@ -107,3 +158,74 @@ export function rerunCustomRun(run) { const uri = getTektonAPI('customruns', { namespace, version: 'v1beta1' }); return post(uri, payload).then(({ body }) => body); } + +export function createCustomRun({ + kind, + labels, + namespace, + nodeSelector, + params, + serviceAccount, + customName, + customRunName = `${customName}-run-${Date.now()}`, + timeout +}) { + const payload = getCustomRunPayload({ + kind, + labels, + namespace, + nodeSelector, + params, + serviceAccount, + customName, + customRunName, + timeout + }); + const uri = getTektonAPI('customruns', { namespace }); + return post(uri, payload).then(({ body }) => body); +} + +export function createCustomRunRaw({ namespace, payload }) { + const uri = getTektonAPI('customruns', { namespace, version: 'v1beta1' }); + return post(uri, payload).then(({ body }) => body); +} + +export function generateNewCustomRunPayload({ customRun, rerun }) { + const { annotations, labels, name, namespace, generateName } = + customRun.metadata; + + const payload = deepClone(customRun); + payload.apiVersion = + payload.apiVersion || 'tekton.dev/v1beta1'; + payload.kind = payload.kind || 'CustomRun'; + + function getGenerateName() { + if (rerun) { + return getGenerateNamePrefixForRerun(name); + } + + return generateName || `${name}-`; + } + + payload.metadata = { + annotations: annotations || {}, + generateName: getGenerateName(), + labels: labels || {}, + namespace + }; + if (rerun) { + payload.metadata.labels['dashboard.tekton.dev/rerunOf'] = name; + } + + removeSystemAnnotations(payload); + removeSystemLabels(payload); + + Object.keys(payload.metadata).forEach( + i => payload.metadata[i] === undefined && delete payload.metadata[i] + ); + + delete payload.status; + + delete payload.spec?.status; + return { namespace, payload }; +} diff --git a/src/containers/App/App.jsx b/src/containers/App/App.jsx index e232038d98..e5e30327bf 100644 --- a/src/containers/App/App.jsx +++ b/src/containers/App/App.jsx @@ -42,6 +42,7 @@ import { ClusterTasks, ClusterTriggerBinding, ClusterTriggerBindings, + CreateCustomRun, CreatePipelineRun, CreateTaskRun, CustomResourceDefinition, @@ -322,6 +323,11 @@ export function App({ lang }) { + + + + + diff --git a/src/containers/CreateCustomRun/CreateCustomRun.jsx b/src/containers/CreateCustomRun/CreateCustomRun.jsx new file mode 100644 index 0000000000..94d9729bed --- /dev/null +++ b/src/containers/CreateCustomRun/CreateCustomRun.jsx @@ -0,0 +1,826 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* istanbul ignore file */ + +import React, { Suspense, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom-v5-compat'; +import keyBy from 'lodash.keyby'; +import yaml from 'js-yaml'; +import { + Button, + Dropdown, + Form, + FormGroup, + InlineNotification, + TextInput +} from 'carbon-components-react'; +import { + ALL_NAMESPACES, + generateId, + getTranslateWithId, + resourceNameRegex, + urls, + useTitleSync +} from '@tektoncd/dashboard-utils'; +import { KeyValueList, Loading } from '@tektoncd/dashboard-components'; +import { useIntl } from 'react-intl'; + +import ClusterTasksDropdown from '../ClusterTasksDropdown'; +import NamespacesDropdown from '../NamespacesDropdown'; +import ServiceAccountsDropdown from '../ServiceAccountsDropdown'; +import TasksDropdown from '../TasksDropdown'; +import { + createCustomRun, + createCustomRunRaw, + generateNewCustomRunPayload, + getCustomRunPayload, + useSelectedNamespace, + useTaskByKind, + useCustomRun +} from '../../api'; +import { isValidLabel } from '../../utils'; + +const YAMLEditor = React.lazy(() => import('../YAMLEditor')); + +const clusterTaskItem = { id: 'clustertask', text: 'ClusterTask' }; +const taskItem = { id: 'task', text: 'Task' }; +const customItem = { id: 'custom', text: 'Custom' }; + +const initialState = { + creating: false, + invalidLabels: {}, + invalidNodeSelector: {}, + kind: 'CustomRun', + labels: [], + namespace: '', + nodeSelector: [], + params: {}, + paramSpecs: [], + serviceAccount: '', + submitError: '', + customRef: '', + customRunName: '', + timeout: '', + validationError: false, + validCustomRunName: true +}; + +const initialParamsState = paramSpecs => { + if (!paramSpecs) { + return {}; + } + return paramSpecs.reduce( + (acc, param) => ({ ...acc, [param.name]: param.default || '' }), + {} + ); +}; + +const itemToString = ({ text }) => text; + +function CreateCustomRun() { + const intl = useIntl(); + const location = useLocation(); + const navigate = useNavigate(); + const { selectedNamespace: defaultNamespace } = useSelectedNamespace(); + + function getCustomDetails() { + const urlSearchParams = new URLSearchParams(location.search); + return { + kind: urlSearchParams.get('kind') || 'Custom', + customName: urlSearchParams.get('customName') || '' + }; + } + + function getCustomRunName() { + const urlSearchParams = new URLSearchParams(location.search); + return urlSearchParams.get('customRunName') || ''; + } + + function getNamespace() { + const urlSearchParams = new URLSearchParams(location.search); + return ( + urlSearchParams.get('namespace') || + (defaultNamespace !== ALL_NAMESPACES ? defaultNamespace : '') + ); + } + + function isYAMLMode() { + const urlSearchParams = new URLSearchParams(location.search); + return urlSearchParams.get('mode') === 'yaml'; + } + + const { kind: initialCustomKind, customName: customRefFromDetails } = + getCustomDetails(); + const [ + { + creating, + invalidLabels, + invalidNodeSelector, + kind, + labels, + namespace, + nodeSelector, + params, + serviceAccount, + submitError, + customRef, + customRunName, + timeout, + validationError, + validCustomRunName + }, + setState + ] = useState({ + ...initialState, + kind: initialCustomKind || 'Custom', + namespace: getNamespace(), + customRef: customRefFromDetails, + params: initialParamsState(null) + }); + + const { data: custom, error: customError } = useTaskByKind( + { kind, name: customRef, namespace }, + { enabled: !!customRef } + ); + + const paramSpecs = custom?.spec?.params; + + useTitleSync({ + page: intl.formatMessage({ + id: 'dashboard.createCustomRun.title', + defaultMessage: 'Create CustomRun' + }) + }); + + function switchToYamlMode() { + const queryParams = new URLSearchParams(location.search); + queryParams.set('mode', 'yaml'); + const browserURL = location.pathname.concat(`?${queryParams.toString()}`); + navigate(browserURL); + } + + function checkFormValidation() { + // Namespace, customRef, and Params must all have values + const validNamespace = !!namespace; + const validCustomRef = !!customRef; + + const paramSpecMap = keyBy(paramSpecs, 'name'); + const validParams = + !params || + Object.keys(params).reduce( + (acc, name) => + acc && + (!!params[name] || + typeof paramSpecMap[name]?.default !== 'undefined'), + true + ); + + // TaskRun name + const customRunNameTest = + !customRunName || + (resourceNameRegex.test(customRunName) && customRunName.length < 64); + setState(state => ({ ...state, validCustomRunName: customRunNameTest })); + + // Labels + let validLabels = true; + labels.forEach(label => { + ['key', 'value'].forEach(type => { + if (!isValidLabel(type, label[type])) { + validLabels = false; + setState(prevState => ({ + ...prevState, + invalidLabels: { + ...prevState.invalidLabels, + [`${label.id}-${type}`]: true + } + })); + } + }); + }); + + // Node selector + let validNodeSelector = true; + nodeSelector.forEach(label => { + ['key', 'value'].forEach(type => { + if (!isValidLabel(type, label[type])) { + validNodeSelector = false; + setState(prevState => ({ + ...prevState, + invalidNodeSelector: { + ...prevState.invalidNodeSelector, + [`${label.id}-${type}`]: true + } + })); + } + }); + }); + + return ( + validNamespace && + validCustomRef && + validParams && + validLabels && + validNodeSelector && + customRunNameTest + ); + } + + function handleClose() { + const { kind: customKind, customName } = getCustomDetails(); + let url = urls.customRuns.all(); + if (customName && namespace && namespace !== ALL_NAMESPACES) { + url = urls.customRuns[ + customKind === 'ClusterTask' ? 'byClusterTask' : 'byTask' + ]({ + namespace, + customName + }); + } else if (namespace && namespace !== ALL_NAMESPACES) { + url = urls.customRuns.byNamespace({ namespace }); + } + navigate(url); + } + + function handleAddLabel(prop) { + setState(prevState => ({ + ...prevState, + [prop]: [ + ...prevState[prop], + { + id: generateId(`label${prevState[prop].length}-`), + key: '', + keyPlaceholder: 'key', + value: '', + valuePlaceholder: 'value' + } + ] + })); + } + + function handleRemoveLabel(prop, invalidProp, index) { + setState(prevState => { + const newLabels = [...prevState[prop]]; + const newInvalidLabels = { ...prevState[invalidProp] }; + const removedLabel = newLabels[index]; + newLabels.splice(index, 1); + if (removedLabel.id in newInvalidLabels) { + delete newInvalidLabels[`${removedLabel.id}-key`]; + delete newInvalidLabels[`${removedLabel.id}-value`]; + } + return { + ...prevState, + [prop]: newLabels, + [invalidProp]: newInvalidLabels + }; + }); + } + + function handleChangeLabel(prop, invalidProp, { type, index, value }) { + setState(prevState => { + const newLabels = [...prevState[prop]]; + newLabels[index][type] = value; + const newInvalidLabels = { ...prevState[invalidProp] }; + if (!isValidLabel(type, value)) { + newInvalidLabels[`${newLabels[index].id}-${type}`] = true; + } else { + delete newInvalidLabels[`${newLabels[index].id}-${type}`]; + } + return { + ...prevState, + [prop]: newLabels, + [invalidProp]: newInvalidLabels + }; + }); + } + + function handleCloseYAMLEditor() { + let url = urls.customRuns.all(); + if (defaultNamespace && defaultNamespace !== ALL_NAMESPACES) { + url = urls.customRuns.byNamespace({ namespace: defaultNamespace }); + } + navigate(url); + } + + function handleCreate({ resource }) { + console.log("DDDDDDDDDDD") + const resourceNamespace = resource?.metadata?.namespace; + return createCustomRunRaw({ + namespace: resourceNamespace, + payload: resource + }).then(() => { + navigate(urls.customRuns.byNamespace({ namespace: resourceNamespace })); + }); + } + + function handleNamespaceChange({ selectedItem }) { + const { text = '' } = selectedItem || {}; + if (text !== namespace) { + setState(state => ({ + ...state, + ...initialState, + kind: state.kind, + namespace: text + })); + + const queryParams = new URLSearchParams(location.search); + if (text) { + queryParams.set('namespace', text); + } else { + queryParams.delete('namespace'); + } + queryParams.delete('customName'); + const browserURL = location.pathname.concat(`?${queryParams.toString()}`); + navigate(browserURL); + } + } + + function handleKindChange({ selectedItem }) { + const { text = '' } = selectedItem || {}; + if (text !== kind) { + setState(state => ({ + ...state, + ...initialState, + kind: text + })); + + const queryParams = new URLSearchParams(location.search); + queryParams.set('kind', text); + queryParams.delete('namespace'); + queryParams.delete('customName'); + const browserURL = location.pathname.concat(`?${queryParams.toString()}`); + navigate(browserURL); + } + } + + function handleParamChange(key, value) { + setState(state => ({ + ...state, + params: { + ...state.params, + [key]: value + } + })); + } + + function handleCustomChange({ selectedItem }) { + const { text } = selectedItem || {}; + + const queryParams = new URLSearchParams(location.search); + if (text) { + queryParams.set('customName', text); + } else { + queryParams.delete('customName'); + } + const browserURL = location.pathname.concat(`?${queryParams.toString()}`); + navigate(browserURL); + + if (text && text !== customRef) { + setState(state => { + return { + ...state, + customRef: text, + params: initialParamsState(paramSpecs) + }; + }); + return; + } + // Reset params when no Task is selected + setState(state => ({ + ...state, + ...initialState, + namespace: state.namespace + })); + } + + function handleSubmit(event) { + event.preventDefault(); + + // Check form validation + const valid = checkFormValidation(); + setState(state => ({ ...state, validationError: !valid })); + if (!valid) { + return; + } + + setState(state => ({ ...state, creating: true })); + + createCustomRun({ + kind, + labels: labels.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}), + namespace, + nodeSelector: nodeSelector.length + ? nodeSelector.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}) + : null, + params, + serviceAccount, + customName: customRef, + customRunName: customRunName || undefined, + timeout + }) + .then(() => { + navigate(urls.customRuns.byNamespace({ namespace })); + }) + .catch(error => { + error.response.text().then(text => { + const statusCode = error.response.status; + let errorMessage = `error code ${statusCode}`; + if (text) { + errorMessage = `${text} (error code ${statusCode})`; + } + setState(state => ({ + ...state, + creating: false, + submitError: errorMessage + })); + }); + }); + } + // for now, only yaml mode is available + // if (isYAMLMode()) { + if (true) { + const externalCustomRunName = getCustomRunName(); + if (externalCustomRunName) { + const { data: customRunObject, isLoading } = useCustomRun( + { + name: externalCustomRunName, + namespace: getNamespace() + }, + { disableWebSocket: true } + ); + let payloadYaml = null; + if (customRunObject) { + const { payload } = generateNewCustomRunPayload({ + customRun: customRunObject, + rerun: false + }); + payloadYaml = yaml.dump(payload); + } + const loadingMessage = intl.formatMessage( + { + id: 'dashboard.loading.resource', + defaultMessage: 'Loading {kind}…' + }, + { kind: 'CustomRun' } + ); + + return ( + }> + + + ); + } + + const customRun = getCustomRunPayload({ + kind, + labels: labels.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}), + namespace, + nodeSelector: nodeSelector.length + ? nodeSelector.reduce((acc, { key, value }) => { + acc[key] = value; + return acc; + }, {}) + : null, + params, + serviceAccount, + customName: customRef, + customRunName: customRunName || undefined, + timeout + }); + + return ( + }> + + + ); + } + + return ( + + + + {intl.formatMessage({ + id: 'dashboard.createCustomRun.title', + defaultMessage: 'Create CustomRun' + })} + + + + {intl.formatMessage({ + id: 'dashboard.create.yamlModeButton', + defaultMessage: 'YAML Mode' + })} + + + + + {customError && ( + + )} + {validationError && ( + + )} + {submitError !== '' && ( + + setState(state => ({ ...state, submitError: '' })) + } + lowContrast + /> + )} + + + + {kind === 'Custom' && ( + + )} + {kind === 'ClusterTask' && ( + + )} + + + `, + '' + ] + ) + }} + /> + } + keyValues={labels} + minKeyValues={0} + invalidFields={invalidLabels} + onChange={label => + handleChangeLabel('labels', 'invalidLabels', label) + } + onRemove={index => + handleRemoveLabel('labels', 'invalidLabels', index) + } + onAdd={() => handleAddLabel('labels')} + /> + + + `, + '' + ] + ) + }} + /> + } + keyValues={nodeSelector} + minKeyValues={0} + invalidFields={invalidNodeSelector} + onChange={label => + handleChangeLabel('nodeSelector', 'invalidNodeSelector', label) + } + onRemove={index => + handleRemoveLabel('nodeSelector', 'invalidNodeSelector', index) + } + onAdd={() => handleAddLabel('nodeSelector')} + /> + + {paramSpecs && paramSpecs.length !== 0 && ( + + {paramSpecs.map(paramSpec => ( + + handleParamChange(paramSpec.name, value) + } + /> + ))} + + )} + + { + const { text } = selectedItem || {}; + setState(state => ({ ...state, serviceAccount: text })); + }} + /> + + setState(state => ({ ...state, timeout: value })) + } + /> + + setState(state => ({ ...state, customRunName: value.trim() })) + } + /> + + + + {intl.formatMessage({ + id: 'dashboard.actions.createButton', + defaultMessage: 'Create' + })} + + + {intl.formatMessage({ + id: 'dashboard.modal.cancelButton', + defaultMessage: 'Cancel' + })} + + + + ); +} + +export default CreateCustomRun; diff --git a/src/containers/CreateCustomRun/index.js b/src/containers/CreateCustomRun/index.js new file mode 100644 index 0000000000..f1c1880a84 --- /dev/null +++ b/src/containers/CreateCustomRun/index.js @@ -0,0 +1,15 @@ +/* +Copyright 2023 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* istanbul ignore file */ + +export { default } from './CreateCustomRun'; diff --git a/src/containers/CustomRuns/CustomRuns.jsx b/src/containers/CustomRuns/CustomRuns.jsx index 4165d6746c..bd61f10f33 100644 --- a/src/containers/CustomRuns/CustomRuns.jsx +++ b/src/containers/CustomRuns/CustomRuns.jsx @@ -14,13 +14,14 @@ limitations under the License. import React, { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useLocation, useParams } from 'react-router-dom-v5-compat'; +import { useLocation, useNavigate, useParams } from 'react-router-dom-v5-compat'; import { useIntl } from 'react-intl'; import keyBy from 'lodash.keyby'; import { ALL_NAMESPACES, getFilters, getStatus, + labels, urls, useTitleSync } from '@tektoncd/dashboard-utils'; @@ -108,10 +109,16 @@ function getRunStatusTooltip(run) { function CustomRuns() { const intl = useIntl(); + const navigate = useNavigate(); const location = useLocation(); const params = useParams(); const filters = getFilters(location); + const customFilter = filters.find(f => f.indexOf(`${CUSTOM}=`) !== -1) || ''; + const customName = customFilter.replace(`${labels.CUSTOM}=`, ''); + + const kind = 'Custom'; + useTitleSync({ page: 'CustomRuns' }); const { selectedNamespace } = useSelectedNamespace(); @@ -243,7 +250,7 @@ function CustomRuns() { }, { actionText: intl.formatMessage({ - id: 'dashboard.cancelTaskRun.actionText', + id: 'dashboard.cancelCustomRun.actionText', defaultMessage: 'Stop' }), action: cancel, @@ -316,15 +323,16 @@ function CustomRuns() { { onClick: () => { let queryString; - if (namespace !== ALL_NAMESPACES || kind !== 'CustomRun') { + if (namespace !== ALL_NAMESPACES || kind !== 'Custom') { queryString = new URLSearchParams({ ...(namespace !== ALL_NAMESPACES && { namespace }), ...(kind && { kind }), - ...(customRunName && { customRunName }) + ...(customName && { customName }) }).toString(); } navigate( - urls.customRuns.create() + (queryString ? `?${queryString}` : '') + // currently default is yaml mode + urls.customRuns.create() + (queryString ? `?${queryString}?mode=yaml` : '?mode=yaml') ); }, text: intl.formatMessage({ diff --git a/src/containers/index.js b/src/containers/index.js index 0af0546481..3ebf235c8d 100644 --- a/src/containers/index.js +++ b/src/containers/index.js @@ -18,6 +18,7 @@ export { default as ClusterTasksDropdown } from './ClusterTasksDropdown'; export { default as ClusterTriggerBinding } from './ClusterTriggerBinding'; export { default as ClusterTriggerBindings } from './ClusterTriggerBindings'; export { default as CustomResourceDefinition } from './CustomResourceDefinition'; +export { default as CreateCustomRun } from './CreateCustomRun'; export { default as CreatePipelineRun } from './CreatePipelineRun'; export { default as CreateTaskRun } from './CreateTaskRun'; export { default as CustomRun } from './CustomRun';