diff --git a/cypress/e2e/task-create.cy.js b/cypress/e2e/task-create.cy.js new file mode 100644 index 000000000..167fdbfac --- /dev/null +++ b/cypress/e2e/task-create.cy.js @@ -0,0 +1,52 @@ +const testTaskScript = `// mode=local,language=javascript,parameters=[greetee] +"Hello " + greetee` + +const updatedScript = `// mode=local,language=javascript,parameters=[greetee] +"Good morning " + greetee` + +describe('Tasks', () => { + beforeEach(() => { + cy.login(Cypress.env('username'), Cypress.env('password')); + }); + + it('successfully navigates through tasks', () => { + cy.get('a[aria-label="nav-item-Tasks"]').click(); + cy.contains('hello'); + }); + + it('successfully creates tasks', () => { + cy.get('a[aria-label="nav-item-Tasks"]').click(); + cy.get('button[aria-label="create-task-button"]').click(); + cy.get('#task-name').click().type('testTask'); + cy.get('.pf-c-code-editor').click().type(testTaskScript) + cy.get('[data-cy="add-task-button"]').click(); + cy.contains('Task testTask has been created'); + cy.get('.pf-c-alert__action > .pf-c-button').click(); //Closing alert popup. + cy.contains('testTask'); + }); + + it('successfully execute a task', () => { + cy.get('a[aria-label="nav-item-Tasks"]').click(); + cy.contains('testTask').click(); + cy.get('button[aria-label="expand-task-testTask"]').click({ force: true }); + cy.get('button[aria-label="execute-button-testTask"]').click(); + cy.get('input[aria-label="input-parameter"]').click().type('world'); + cy.get('button[aria-label="Confirm"]').click(); + cy.contains('The script has been successfully executed'); + cy.get('.pf-c-alert__action > .pf-c-button').click(); //Closing alert popup. + }) + + it('successfully update a task', () => { + cy.get('a[aria-label="nav-item-Tasks"]').click(); + cy.contains('testTask').click(); + cy.get('button[aria-label="expand-task-testTask"]').click({ force: true }); + cy.get('button[aria-label="edit-button-testTask"]').click(); + cy.get('.pf-c-code-editor').type('{selectall}', { timeout: 10000 }); + cy.get('.pf-c-code-editor').click().type(updatedScript); + cy.get('button[aria-label="edit-button-testTask"]').click(); + cy.contains('Task testTask has been updated'); + cy.get('.pf-c-alert__action > .pf-c-button').click(); //Closing alert popup. + cy.contains('testTask'); + cy.contains('"Good morning " + greetee'); + }) +}); diff --git a/src/app/CacheManagers/CacheManagers.tsx b/src/app/CacheManagers/CacheManagers.tsx index b0ae05d31..bfd426eed 100644 --- a/src/app/CacheManagers/CacheManagers.tsx +++ b/src/app/CacheManagers/CacheManagers.tsx @@ -71,7 +71,7 @@ const CacheManagers = () => { { name: t('cache-managers.counters-tab'), count: countersCount, key: '1' } ]; - if (ConsoleServices.security().hasConsoleACL(ConsoleACL.BULK_READ, connectedUser)) { + if (ConsoleServices.security().hasConsoleACL(ConsoleACL.EXEC, connectedUser)) { tabs.push({ name: t('cache-managers.tasks-tab'), count: tasksCount, key: '2' }); } diff --git a/src/app/CacheManagers/TasksTableDisplay.tsx b/src/app/CacheManagers/TasksTableDisplay.tsx index e163b69a4..08acbf6fe 100644 --- a/src/app/CacheManagers/TasksTableDisplay.tsx +++ b/src/app/CacheManagers/TasksTableDisplay.tsx @@ -1,181 +1,299 @@ import React, { useEffect, useState } from 'react'; -import { Table, TableBody, TableHeader, TableVariant } from '@patternfly/react-table'; import { - Badge, + Alert, + Button, Bullseye, EmptyState, EmptyStateBody, EmptyStateIcon, EmptyStateVariant, + ButtonVariant, + DataList, + DataListCell, + DataListContent, + DataListItem, + DataListItemCells, + DataListItemRow, + DataListToggle, Pagination, + Title, + Toolbar, + ToolbarItem, + ToolbarContent, + ToolbarGroup, + ToolbarItemVariant, Stack, StackItem, - Text, - TextContent, - TextVariants, - Title + Spinner } from '@patternfly/react-core'; -import { SearchIcon } from '@patternfly/react-icons'; -import displayUtils from '@services/displayUtils'; -import { - chart_color_blue_500, - global_FontSize_sm, - global_spacer_md, - global_spacer_sm, - global_spacer_xs -} from '@patternfly/react-tokens'; +import { CodeEditor } from '@patternfly/react-code-editor'; +import SyntaxHighlighter from 'react-syntax-highlighter'; +import { githubGist } from 'react-syntax-highlighter/dist/esm/styles/hljs'; import { useTranslation } from 'react-i18next'; +import { useFetchTask } from '@app/services/tasksHook'; +import { ExecuteTasks } from '@app/Tasks/ExecuteTasks'; import { ConsoleServices } from '@services/ConsoleServices'; +import { ConsoleACL } from '@services/securityService'; +import { useConnectedUser } from '@app/services/userManagementHook'; +import { CreateTask } from '@app/Tasks/CreateTask'; +import { SearchIcon } from '@patternfly/react-icons'; +import { useApiAlert } from '@app/utils/useApiAlert'; const TasksTableDisplay = (props: { setTasksCount: (number) => void; isVisible: boolean }) => { - const [tasks, setTasks] = useState([]); + const { addAlert } = useApiAlert(); + const { t } = useTranslation(); + const brandname = t('brandname.brandname'); + const { tasks, loading, error, reload } = useFetchTask(); const [filteredTasks, setFilteredTasks] = useState([]); - const [tasksPagination, setTasksPagination] = useState({ page: 1, perPage: 10 }); - const [rows, setRows] = useState<(string | any)[]>([]); - const { t } = useTranslation(); - const brandname = t('brandname.brandname'); + const [taskToExecute, setTaskToExecute] = useState(); + const [isCreateTask, setIsCreateTask] = useState(false); + const { connectedUser } = useConnectedUser(); + const [expanded, setExpanded] = useState([]); + const [editTaskName, setEditTaskName] = useState(''); + const [editScript, setEditScript] = useState(''); + const [scriptContent, setScriptContent] = useState(new Map()); + const [scriptError, setScriptError] = useState(''); - const columns = [ - { title: t('cache-managers.task-name') }, - { - title: t('cache-managers.task-type') - }, - { - title: t('cache-managers.context-name') - }, - { - title: t('cache-managers.operation-name') - }, - { - title: t('cache-managers.parameters') - }, - { - title: t('cache-managers.allowed-role') + useEffect(() => { + if (loading) { + props.setTasksCount(tasks.length); + const initSlice = (tasksPagination.page - 1) * tasksPagination.perPage; + setFilteredTasks(tasks.slice(initSlice, initSlice + tasksPagination.perPage)); } - ]; + }, [loading, tasks, error]); useEffect(() => { - ConsoleServices.tasks() - .getTasks() - .then((maybeTasks) => { - if (maybeTasks.isRight()) { - setTasks(maybeTasks.value); - setFilteredTasks(maybeTasks.value); - props.setTasksCount(maybeTasks.value.length); - const initSlice = (tasksPagination.page - 1) * tasksPagination.perPage; - updateRows(maybeTasks.value.slice(initSlice, initSlice + tasksPagination.perPage)); - } else { - // TODO: deal loading, error, empty status - } - }); - }, []); + if (filteredTasks) { + const initSlice = (tasksPagination.page - 1) * tasksPagination.perPage; + setFilteredTasks(tasks.slice(initSlice, initSlice + tasksPagination.perPage)); + } + }, [tasksPagination]); const onSetPage = (_event, pageNumber) => { setTasksPagination({ page: pageNumber, perPage: tasksPagination.perPage }); - const initSlice = (pageNumber - 1) * tasksPagination.perPage; - updateRows(filteredTasks.slice(initSlice, initSlice + tasksPagination.perPage)); }; const onPerPageSelect = (_event, perPage) => { setTasksPagination({ - page: tasksPagination.page, + page: 1, perPage: perPage }); - const initSlice = (tasksPagination.page - 1) * perPage; - updateRows(filteredTasks.slice(initSlice, initSlice + perPage)); }; - const taskType = (type: string) => { + const loadScript = (taskName: string) => { + ConsoleServices.tasks() + .fetchScript(taskName) + .then((eitherResponse) => { + if (eitherResponse.isRight()) { + scriptContent.set(taskName, eitherResponse.value); + } else { + scriptContent.set(taskName, t('cache-managers.tasks.script-load-error')); + } + }); + }; + + const handleEdit = (taskName: string) => { + if (editTaskName == '' || editTaskName != taskName) { + setEditTaskName(taskName); + setEditScript(scriptContent.get(taskName) as string); + } else { + // save script + + if (!scriptContent.has(taskName) || scriptContent.get(taskName) == '') { + return; + } + + // Do not update if script not changed + if (scriptContent.get(taskName) == editScript) { + setEditTaskName(''); + setEditScript(''); + return; + } + + ConsoleServices.tasks() + .createOrUpdateTask(taskName, editScript, false) + .then((actionResponse) => { + if (actionResponse.success) { + setScriptError(''); + setEditTaskName(''); + addAlert(actionResponse); + loadScript(taskName); + } else { + setScriptError(actionResponse.message); + } + }) + .then(() => reload()); + } + }; + + const buildCreateTaskButton = () => { + if (!ConsoleServices.security().hasConsoleACL(ConsoleACL.CREATE, connectedUser)) { + return ; + } return ( - - {type} - + + + + + ); }; - const taskParameters = (params: [string]) => { + const toggle = (taskName) => { + const index = expanded.indexOf(taskName); + const newExpanded = + index >= 0 + ? [...expanded.slice(0, index), ...expanded.slice(index + 1, expanded.length)] + : [...expanded, taskName]; + setExpanded(newExpanded); + }; + + const errorInScript = (taskName) => { + return scriptContent.get(taskName) === t('cache-managers.tasks.script-load-error'); + }; + + const buildTaskToolbar = (taskName) => { + if (!ConsoleServices.security().hasConsoleACL(ConsoleACL.CREATE, connectedUser)) { + return ''; + } + return ( - - {params.map((param, index) => ( - - {' [' + param + ']'} - - ))} - + + + + + + + + + + ); }; - const taskAllowedRoles = (allowedRole: string) => { - if (allowedRole == null || allowedRole.trim().length == 0) { - return {t('cache-managers.allowed-role-null')}; + const buildTaskScriptContent = (name) => { + if (!scriptContent.get(name)) { + loadScript(name); + return ; + } + if (editTaskName != name) { + return ( + + {scriptContent.get(name)} + + ); } return ( - - {allowedRole} - + <> + {scriptError.length > 0 && ( + + )} + setEditScript(v)} + height="200px" + /> + ); }; - const updateRows = (tasks: Task[]) => { - let rows: { heightAuto: boolean; cells: (string | any)[] }[]; - - if (tasks.length == 0) { - rows = [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 8 }, - title: ( - - - - - {t('cache-managers.no-tasks-status')} - - {t('cache-managers.no-tasks-body')} - - - ) - } - ] - } - ]; - } else { - rows = tasks.map((task) => { - return { - heightAuto: true, - cells: [ - { title: task.name }, - { title: taskType(task.type) }, - { title: task.task_context_name }, - { title: task.task_operation_name }, - { title: taskParameters(task.parameters) }, - { title: taskAllowedRoles(task.allowed_role) } - ] - //TODO {title: }] - }; - }); + const buildTasksList = () => { + if (filteredTasks.length == 0) { + return ( + + + + + {t('cache-managers.tasks.no-tasks-status')} + + {t('cache-managers.tasks.no-tasks-body')} + + + ); } - setRows(rows); + + return ( + + {filteredTasks.map((task) => { + return ( + + + toggle(task.name)} + isExpanded={expanded.includes(task.name)} + id={task.name} + aria-label={'expand-task-' + task.name} + aria-controls={'ex-' + task.name} + /> + + {task.name} + , + + {expanded.includes(task.name) ? buildTaskToolbar(task) : ''} + + ]} + /> + + + {buildTaskScriptContent(task.name)} + + + ); + })} + + ); }; if (!props.isVisible) { @@ -183,27 +301,43 @@ const TasksTableDisplay = (props: { setTasksCount: (number) => void; isVisible: } return ( - + - + + {buildCreateTaskButton()} + + + + + + + {buildTasksList()} + + { + setTaskToExecute(undefined); + reload(); + }} + /> + { + setIsCreateTask(false); + reload(); + }} + closeModal={() => setIsCreateTask(false)} /> - - - -
); diff --git a/src/app/Tasks/CreateTask.tsx b/src/app/Tasks/CreateTask.tsx new file mode 100644 index 000000000..f136707c9 --- /dev/null +++ b/src/app/Tasks/CreateTask.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import { + Alert, + AlertVariant, + Button, + ButtonVariant, + Form, + FormGroup, + Modal, + ModalVariant, + TextInput +} from '@patternfly/react-core'; +import { useTranslation } from 'react-i18next'; +import { CodeEditor } from '@patternfly/react-code-editor'; +import formUtils, { IField } from '@services/formUtils'; +import { PopoverHelp } from '@app/Common/PopoverHelp'; +import { useApiAlert } from '@app/utils/useApiAlert'; +import { ConsoleServices } from '@services/ConsoleServices'; + +const CreateTask = (props: { isModalOpen: boolean; submitModal: () => void; closeModal: () => void }) => { + const { t } = useTranslation(); + const brandname = t('brandname.brandname'); + const { addAlert } = useApiAlert(); + + const nameInitialState: IField = { + value: '', + isValid: false, + validated: 'default' + }; + + const scriptInitialState: IField = { + value: '', + isValid: false, + validated: 'default' + }; + + const [name, setName] = useState(nameInitialState); + const [script, setScript] = useState(scriptInitialState); + const [error, setError] = useState(''); + + const handleSubmit = () => { + let isValid = true; + isValid = + formUtils.validateRequiredField(name.value.trim(), t('cache-managers.tasks.task-name'), setName) && isValid; + isValid = + formUtils.validateRequiredField(script.value.trim(), t('cache-managers.tasks.script'), setScript) && isValid; + + if (isValid) { + ConsoleServices.tasks() + .createOrUpdateTask(name.value, script.value, true) + .then((actionResponse) => { + if (actionResponse.success) { + setName(nameInitialState); + setScript(scriptInitialState); + setError(''); + addAlert(actionResponse); + props.submitModal(); + } else { + setError(actionResponse.message); + } + }); + } + }; + + const closeModal = () => { + props.closeModal(); + setName(nameInitialState); + setScript(scriptInitialState); + setError(''); + }; + + return ( + + {t('cache-managers.tasks.confirm')} + , + + ]} + > +
{ + e.preventDefault(); + }} + > + {error !== '' && } + + formUtils.validateRequiredField(value, t('cache-managers.tasks.task-name'), setName)} + /> + + + } + helperTextInvalid={script.invalidText} + isRequired + > + formUtils.validateRequiredField(value, t('cache-managers.tasks.script'), setScript)} + height="200px" + /> + + +
+ ); +}; + +export { CreateTask }; diff --git a/src/app/Tasks/ExecuteTasks.tsx b/src/app/Tasks/ExecuteTasks.tsx new file mode 100644 index 000000000..0e28dadb3 --- /dev/null +++ b/src/app/Tasks/ExecuteTasks.tsx @@ -0,0 +1,98 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, + ButtonVariant, + Form, + FormGroup, + Modal, + ModalVariant, + Text, + TextContent, + TextInput +} from '@patternfly/react-core'; +import { useExecuteTask } from '@app/services/tasksHook'; +import { useTranslation } from 'react-i18next'; + +const ExecuteTasks = (props: { task; isModalOpen: boolean; closeModal: () => void }) => { + const { t } = useTranslation(); + const [paramsValue, setParamsValue] = useState({}); + const { onExecute } = useExecuteTask(props.task?.name, paramsValue); + + useEffect(() => { + const obj = props.task?.parameters?.reduce((o, key) => ({ ...o, [key]: '' }), {}); + setParamsValue(obj); + }, [props.task]); + + const onValueChange = (parameter, value) => { + setParamsValue((prevState) => ({ + ...prevState, + [parameter]: value + })); + }; + + const formParameters = () => { + return ( + + + + There are multiple parameters on the script {props.task?.name}. Enter value for the + parameters to run script + + + {props.task !== undefined && ( +
e.preventDefault()}> + {props.task.parameters.map((p) => { + return ( + + onValueChange(p, val)} /> + + ); + })} +
+ )} +
+ ); + }; + + const formWithoutParameter = () => { + return ( + + + Do you want to execute {props.task?.name} ? + + + ); + }; + + return ( + { + onExecute(); + props.closeModal(); + }} + > + {t('cache-managers.tasks.execute')} + , + + ]} + > + {props.task?.parameters.length !== 0 ? formParameters() : formWithoutParameter()} + + ); +}; + +export { ExecuteTasks }; diff --git a/src/app/assets/languages/en.json b/src/app/assets/languages/en.json index f06ba92c5..2ec2cefcd 100644 --- a/src/app/assets/languages/en.json +++ b/src/app/assets/languages/en.json @@ -84,16 +84,6 @@ "cache-filter-feature-xsite": "Backups", "cache-filter-feature-ignored": "Hidden", "counters-table-label": "Counters", - "tasks-table-label": "Tasks", - "task-name": "Name", - "task-type": "Type", - "context-name": "Context name", - "operation-name": "Operation name", - "parameters": "Parameters", - "allowed-role": "Allowed role", - "allowed-role-null": "-", - "no-tasks-status": "No tasks yet", - "no-tasks-body": "Create tasks with the CLI or a remote client.", "rebalancing": { "disabled-status": "Cluster rebalancing off", "enabled": "Cluster rebalancing on", @@ -140,10 +130,36 @@ "modal-storage": "Counter storage", "modal-strong-counter": "Strong counter", "modal-weak-counter": "Weak counter", - "modal-initial-value-invalid": "The initial value must be between the lower bound and the upper bound inclusively", + "modal-initial-value-invalid": "The initial value must be greater than the lower bound and less then or equal to the upper bound", "modal-lower-bound-invalid": "The lower bound must be less than the upper bound", "modal-storage-tooltip": "{{brandname}} can save counter values in a persistent storage so the data is available when clusters restart. If you select a volatile storage, counter values are permanently deleted when clusters restart or stop", "modal-counter-type-tooltip": "The value of a strong counter is stored in a single key for consistency. {{brandname}} recommends using strong counters when the counter's value is needed after each update or when a bounded counter is needed. The value of a weak counter is stored in multiple keys. The weak counter is suitable for use cases where the result of the update operation is not needed or the counter’s value is not required too often " + }, + "tasks": { + "tasks-table-label": "Tasks", + "name": "Name", + "task-type": "Type", + "context-name": "Context name", + "operation-name": "Operation name", + "parameters": "Parameters", + "allowed-role": "Security role", + "allowed-role-null": "-", + "no-tasks-status": "No tasks yet", + "no-tasks-body": "Click \"Create a task\" to upload a script. You can also add scripts from the CLI or remote clients.", + "execute": "Execute", + "execution": "Execution", + "cancel": "Cancel", + "create-task": "Create a script task", + "task-name": "Task name", + "script": "Script", + "provide-script": "Provide your script implementation", + "script-tooltip": "Define a script using a scripting language such as JavaScript. Include script metadata to provide information about your script such as the language, mode, and parameters. To add a script, you must have compatible ScriptEngine installed.", + "confirm": "Confirm", + "save-button": "Save", + "edit-button": "Edit", + "execute-button": "Execute", + "cancel-button": "Cancel", + "script-load-error": "An error occurred. Try closing the tab and opening it again." } }, "caches": { diff --git a/src/app/services/tasksHook.ts b/src/app/services/tasksHook.ts new file mode 100644 index 000000000..69a0dd429 --- /dev/null +++ b/src/app/services/tasksHook.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react'; +import { useApiAlert } from '@app/utils/useApiAlert'; +import { ConsoleServices } from '@services/ConsoleServices'; + +export function useFetchTask() { + const [tasks, setTasks] = useState([]); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (loading) { + ConsoleServices.tasks() + .getTasks() + .then((either) => { + if (either.isRight()) { + setTasks(either.value); + } else { + setError(either.value.message); + } + }) + .finally(() => setLoading(false)); + } + }, [loading]); + + const reload = () => { + setLoading(true); + }; + + return { + loading, + tasks, + error, + reload + }; +} + +export function useExecuteTask(name: string, params) { + const { addAlert } = useApiAlert(); + + const onExecute = () => { + ConsoleServices.tasks() + .executeTask(name, params) + .then((actionResponse) => { + addAlert(actionResponse); + }); + }; + return { + onExecute + }; +} diff --git a/src/services/securityService.ts b/src/services/securityService.ts index 311ce743c..ec00f5ede 100644 --- a/src/services/securityService.ts +++ b/src/services/securityService.ts @@ -9,7 +9,8 @@ export enum ConsoleACL { BULK_READ = 'BULK_READ', BULK_WRITE = 'BULK_WRITE', CREATE = 'CREATE', - ADMIN = 'ADMIN' + ADMIN = 'ADMIN', + EXEC = 'EXEC' } export enum ACL { @@ -136,6 +137,9 @@ export class SecurityService { case ConsoleACL.CREATE: hasAcl = aclList.includes(ACL.CREATE); break; + case ConsoleACL.EXEC: + hasAcl = aclList.includes(ACL.EXEC); + break; } return hasAcl; } diff --git a/src/services/tasksService.ts b/src/services/tasksService.ts index ba5f3c90d..e05988108 100644 --- a/src/services/tasksService.ts +++ b/src/services/tasksService.ts @@ -10,6 +10,16 @@ export class TasksService { this.utils = restUtils; } + private createExecuteTaskURL(params) { + let str = ''; + for (let p in params) { + if (Object.prototype.hasOwnProperty.call(params, p)) { + str += '¶m.' + p + '=' + params[p]; + } + } + return str; + } + public getTasks(): Promise> { return this.utils.get(this.endpoint + '?type=user', (tasks) => tasks.map( @@ -26,4 +36,35 @@ export class TasksService { ) ); } + + public executeTask(name, params): Promise { + const parameterURL = this.createExecuteTaskURL(params); + return this.utils.post({ + url: this.endpoint + '/' + name + '?action=exec' + parameterURL, + successMessage: `The script has been successfully executed`, + errorMessage: `Unexpected error executing.` + }); + } + + public createOrUpdateTask(name: string, script: string, create: boolean): Promise { + if (create) { + return this.utils.post({ + url: this.endpoint + '/' + name, + successMessage: `Task ${name} has been created`, + errorMessage: `Unexpected error creating task ${name}`, + body: script + }); + } + + return this.utils.put({ + url: this.endpoint + '/' + name, + successMessage: `Task ${name} has been updated`, + errorMessage: `Unexpected error updating task ${name}`, + body: script + }); + } + + public fetchScript(name: string): Promise> { + return this.utils.get(this.endpoint + '/' + name + '?action=script', (script) => script, undefined, true); + } } diff --git a/src/types/InfinispanTypes.ts b/src/types/InfinispanTypes.ts index 13a274d34..0b7c8c864 100644 --- a/src/types/InfinispanTypes.ts +++ b/src/types/InfinispanTypes.ts @@ -186,13 +186,13 @@ interface ConnectedUser { } interface Task { - parameters: [string]; - task_context_name: string; - task_operation_name: string; + parameters?: [string]; + task_context_name?: string; + task_operation_name?: string; name: string; type: string; execution_mode: string; - allowed_role: string; + allowed_role?: string; } interface Counter {