diff --git a/api/http/handler/edgejobs/edgejob_update.go b/api/http/handler/edgejobs/edgejob_update.go index ef02a3531..3dba17d83 100644 --- a/api/http/handler/edgejobs/edgejob_update.go +++ b/api/http/handler/edgejobs/edgejob_update.go @@ -48,7 +48,7 @@ func (payload *edgeJobUpdatePayload) Validate(r *http.Request) error { // @failure 500 // @failure 400 // @failure 503 "Edge compute features are disabled" -// @router /edge_jobs/{id} [post] +// @router /edge_jobs/{id} [put] func (handler *Handler) edgeJobUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { edgeJobID, err := request.RetrieveNumericRouteVariableValue(r, "id") if err != nil { diff --git a/app/edge/__module.js b/app/edge/__module.js index d507ed391..00919c7da 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -106,15 +106,12 @@ angular const edgeJob = { name: 'edge.jobs.job', - url: '/:id', + url: '/:id?tab', views: { 'content@': { - component: 'edgeJobView', + component: 'edgeJobsItemView', }, }, - params: { - tab: 0, - }, }; const edgeJobCreation = { diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html deleted file mode 100644 index 93fd17f9c..000000000 --- a/app/edge/components/edge-job-form/edgeJobForm.html +++ /dev/null @@ -1,206 +0,0 @@ -
-
Edge job configuration
- -
- -
- -
-
-
-

- This field is required. -

-

- Allowed characters are: [a-zA-Z0-9_.-] -

-
-
-
-
-
- - - - -
Edge job configuration
- - - - - -
-
- -
- -
-
- -
-
- -
- -
-
Time should be set according to the chosen environments' timezone.
-
-
-
-

- This field is required. -

-
-
-
-
-
- - -
-
- -
- -
-
-
-
-

- This field is required. -

-
-
-
-
-
- -
- - -
-
- -
- -
-
-
-

- This field is required. -

-

- This field format is invalid. -

-
-
-
-
-
Time should be set according to the chosen environments' timezone.
-
-
- - - -
-
Job content
- -
- - - - -
-
Web editor
-
-
- -
-
-
- - -
-
Upload
-
- You can upload a script file from your computer. -
-
-
- - - {{ $ctrl.model.File.name }} - - -
-
-
- - - - -
Target environments
- - - - -
Actions
-
-
- - {{ $ctrl.state.formValidationError }} -
-
- -
diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js deleted file mode 100644 index 814eb19bb..000000000 --- a/app/edge/components/edge-job-form/edgeJobFormController.js +++ /dev/null @@ -1,142 +0,0 @@ -import moment from 'moment'; -import { editor, upload } from '@@/BoxSelector/common-options/build-methods'; - -import { cronMethodOptions } from '@/react/edge/edge-jobs/CreateView/cron-method-options'; - -export class EdgeJobFormController { - /* @ngInject */ - constructor($async, $scope) { - this.$scope = $scope; - this.$async = $async; - - this.cronMethods = cronMethodOptions.map((o) => ({ ...o, id: o.id + '-old' })); - this.buildMethods = [editor, upload].map((o) => ({ ...o, id: o.id + '-old' })); - - this.state = { - formValidationError: '', - }; - - this.scheduleValues = [ - { - displayed: 'Every hour', - cron: '0 * * * *', - }, - { - displayed: 'Every 2 hours', - cron: '0 */2 * * *', - }, - { - displayed: 'Every day', - cron: '0 0 * * *', - }, - ]; - - this.formValues = { - datetime: moment(), - scheduleValue: this.scheduleValues[0], - cronMethod: 'basic', - method: 'editor', - }; - - // see https://regexr.com/573i2 - this.cronRegex = - /(@(annually|yearly|monthly|weekly|daily|hourly|reboot))|(@every (\d+(ns|us|µs|ms|s|m|h))+)|((((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*) ){4,6}((\d+,)+\d+|(\d+(\/|-)\d+)|\d+|\*))/; - - this.action = this.action.bind(this); - this.editorUpdate = this.editorUpdate.bind(this); - this.onChangeEnvironments = this.onChangeEnvironments.bind(this); - this.onChangeGroups = this.onChangeGroups.bind(this); - this.onChange = this.onChange.bind(this); - this.onCronMethodChange = this.onCronMethodChange.bind(this); - this.onBuildMethodChange = this.onBuildMethodChange.bind(this); - } - - onChange(values) { - this.$scope.$evalAsync(() => { - this.formValues = { - ...this.formValues, - ...values, - }; - }); - } - - onBuildMethodChange(value) { - this.onChange({ method: value }); - } - - onCronMethodChange(value) { - this.onChange({ cronMethod: value }); - } - - onChangeModel(model) { - const defaultTime = moment().add('hours', 1); - const scheduled = this.scheduleValues.find((v) => v.cron === model.CronExpression); - - this.formValues = { - datetime: model.CronExpression ? cronToDatetime(model.CronExpression, defaultTime) : defaultTime, - scheduleValue: scheduled || this.scheduleValues[0], - cronMethod: model.Recurring && !scheduled ? 'advanced' : 'basic', - method: this.formValues.method, - }; - } - - onChangeGroups(groups) { - return this.$scope.$evalAsync(() => { - this.model.EdgeGroups = groups ? groups : []; - }); - } - - action() { - this.state.formValidationError = ''; - - if (this.formValues.method === 'editor' && this.model.FileContent === '') { - this.state.formValidationError = 'Script file content must not be empty'; - return; - } - - if (this.formValues.cronMethod === 'basic') { - if (!this.model.Recurring && (this.formValues.datetime === undefined || !this.formValues.datetime.isValid())) { - this.state.formValidationError = 'Schedule date must not be empty'; - return; - } else if (!this.model.Recurring) { - this.model.CronExpression = datetimeToCron(this.formValues.datetime); - } else { - this.model.CronExpression = this.formValues.scheduleValue.cron; - } - } else { - this.model.Recurring = true; - } - - this.formAction(this.formValues.method); - } - - editorUpdate(value) { - this.model.FileContent = value; - this.isEditorDirty = true; - } - - onChangeEnvironments(value) { - return this.$scope.$evalAsync(() => { - this.model.Endpoints = value; - }); - } - - $onInit() { - this.onChangeModel(this.model); - } -} - -function cronToDatetime(cron, defaultTime = moment()) { - var strings = cron.split(' '); - if (strings.length > 4) { - strings = strings.slice(0, 4); - } else { - return defaultTime; - } - return moment(cron, 'm H D M'); -} - -function datetimeToCron(datetime) { - var date = moment(datetime); - return [date.minutes(), date.hours(), date.date(), date.month() + 1, '*'].join(' '); -} diff --git a/app/edge/components/edge-job-form/index.js b/app/edge/components/edge-job-form/index.js deleted file mode 100644 index 96dfea138..000000000 --- a/app/edge/components/edge-job-form/index.js +++ /dev/null @@ -1,20 +0,0 @@ -import angular from 'angular'; - -import { EdgeJobFormController } from './edgeJobFormController'; - -angular.module('portainer.edge').component('edgeJobForm', { - templateUrl: './edgeJobForm.html', - controller: EdgeJobFormController, - bindings: { - model: '=', - groups: '<', - tags: '<', - edgeGroups: '<', - addLabelAction: '<', - removeLabelAction: '<', - formAction: '<', - formActionLabel: '@', - actionInProgress: '<', - isEditorDirty: '=', - }, -}); diff --git a/app/edge/react/components/edge-jobs.ts b/app/edge/react/components/edge-jobs.ts deleted file mode 100644 index 0b88bc3d7..000000000 --- a/app/edge/react/components/edge-jobs.ts +++ /dev/null @@ -1,18 +0,0 @@ -import angular from 'angular'; - -import { r2a } from '@/react-tools/react2angular'; -import { withUIRouter } from '@/react-tools/withUIRouter'; -import { ResultsDatatable } from '@/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable'; - -export const edgeJobsModule = angular - .module('portainer.edge.react.components.edge-jobs', []) - .component( - 'edgeJobResultsDatatable', - r2a(withUIRouter(ResultsDatatable), [ - 'dataset', - 'onClearLogs', - 'onCollectLogs', - 'onDownloadLogs', - 'onRefresh', - ]) - ).name; diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 916005936..b4913e51c 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -9,10 +9,8 @@ import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInInt import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; -import { edgeJobsModule } from './edge-jobs'; - const ngModule = angular - .module('portainer.edge.react.components', [edgeJobsModule]) + .module('portainer.edge.react.components', []) .component( 'edgeGroupsSelector', diff --git a/app/edge/react/views/jobs.ts b/app/edge/react/views/jobs.ts index 7718cffba..1b3aa26ee 100644 --- a/app/edge/react/views/jobs.ts +++ b/app/edge/react/views/jobs.ts @@ -5,10 +5,15 @@ import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withUIRouter } from '@/react-tools/withUIRouter'; import { ListView } from '@/react/edge/edge-jobs/ListView'; import { CreateView } from '@/react/edge/edge-jobs/CreateView/CreateView'; +import { ItemView } from '@/react/edge/edge-jobs/ItemView/ItemView'; export const jobsModule = angular .module('portainer.edge.react.views.jobs', []) .component('edgeJobsView', r2a(withUIRouter(withCurrentUser(ListView)), [])) + .component( + 'edgeJobsItemView', + r2a(withUIRouter(withCurrentUser(ItemView)), []) + ) .component( 'edgeJobsCreateView', r2a(withUIRouter(withCurrentUser(CreateView)), []) diff --git a/app/edge/rest/edge-job-results.js b/app/edge/rest/edge-job-results.js deleted file mode 100644 index 3fb3e658f..000000000 --- a/app/edge/rest/edge-job-results.js +++ /dev/null @@ -1,14 +0,0 @@ -angular.module('portainer.edge').factory('EdgeJobResults', EdgeJobResultsFactory); - -function EdgeJobResultsFactory($resource, API_ENDPOINT_EDGE_JOBS) { - return $resource( - API_ENDPOINT_EDGE_JOBS + '/:id/tasks/:taskId/:action', - {}, - { - query: { method: 'GET', isArray: true, params: { id: '@id' } }, - logFile: { method: 'GET', params: { id: '@id', taskId: '@taskId', action: 'logs' } }, - clearLogs: { method: 'DELETE', params: { id: '@id', taskId: '@taskId', action: 'logs' } }, - collectLogs: { method: 'POST', params: { id: '@id', taskId: '@taskId', action: 'logs' } }, - } - ); -} diff --git a/app/edge/rest/edge-jobs.js b/app/edge/rest/edge-jobs.js deleted file mode 100644 index 05a3f48d8..000000000 --- a/app/edge/rest/edge-jobs.js +++ /dev/null @@ -1,17 +0,0 @@ -angular.module('portainer.edge').factory('EdgeJobs', EdgeJobsFactory); - -function EdgeJobsFactory($resource, API_ENDPOINT_EDGE_JOBS) { - return $resource( - API_ENDPOINT_EDGE_JOBS + '/:id/:action', - {}, - { - create: { method: 'POST', params: { id: 'create', action: '@method' } }, - query: { method: 'GET', isArray: true }, - get: { method: 'GET', params: { id: '@id' } }, - update: { method: 'PUT', params: { id: '@id' } }, - remove: { method: 'DELETE', params: { id: '@id' } }, - file: { method: 'GET', params: { id: '@id', action: 'file' } }, - tasks: { method: 'GET', isArray: true, params: { id: '@id', action: 'tasks' } }, - } - ); -} diff --git a/app/edge/services/edge-job.js b/app/edge/services/edge-job.js deleted file mode 100644 index 3eb94d0d3..000000000 --- a/app/edge/services/edge-job.js +++ /dev/null @@ -1,79 +0,0 @@ -import angular from 'angular'; -import _ from 'lodash-es'; - -import { ScheduleCreateRequest, ScheduleUpdateRequest } from '@/portainer/models/schedule'; - -function EdgeJobService(EdgeJobs, EdgeJobResults, FileUploadService) { - var service = {}; - - service.edgeJob = edgeJob; - async function edgeJob(edgeJobId) { - try { - return await EdgeJobs.get({ id: edgeJobId }).$promise; - } catch (err) { - throw { msg: 'Unable to retrieve edgeJob', err: err }; - } - } - - service.edgeJobs = edgeJobs; - async function edgeJobs() { - try { - return await EdgeJobs.query().$promise; - } catch (err) { - throw { msg: 'Unable to retrieve edgeJobs', err: err }; - } - } - - service.jobResults = jobResults; - async function jobResults(edgeJobId) { - try { - const results = await EdgeJobResults.query({ id: edgeJobId }).$promise; - - return _.sortBy(results, ['Id']); - } catch (err) { - throw { msg: 'Unable to retrieve results associated to the edgeJob', err: err }; - } - } - - service.logFile = logFile; - function logFile(id, taskId) { - return EdgeJobResults.logFile({ id, taskId }).$promise; - } - - service.collectLogs = collectLogs; - function collectLogs(id, taskId) { - return EdgeJobResults.collectLogs({ id, taskId }).$promise; - } - - service.clearLogs = clearLogs; - function clearLogs(id, taskId) { - return EdgeJobResults.clearLogs({ id, taskId }).$promise; - } - - service.createEdgeJobFromFileContent = function (model) { - var payload = new ScheduleCreateRequest(model); - return EdgeJobs.create({}, { method: 'string', ...payload }).$promise; - }; - - service.createEdgeJobFromFileUpload = function (model) { - var payload = new ScheduleCreateRequest(model); - return FileUploadService.createSchedule(payload); - }; - - service.updateEdgeJob = function (model) { - var payload = new ScheduleUpdateRequest(model); - return EdgeJobs.update(payload).$promise; - }; - - service.remove = function (edgeJobId) { - return EdgeJobs.remove({ id: edgeJobId }).$promise; - }; - - service.getScriptFile = function (edgeJobId) { - return EdgeJobs.file({ id: edgeJobId }).$promise; - }; - - return service; -} - -angular.module('portainer.edge').factory('EdgeJobService', EdgeJobService); diff --git a/app/edge/views/edge-jobs/edgeJob/edgeJob.html b/app/edge/views/edge-jobs/edgeJob/edgeJob.html deleted file mode 100644 index 152e59094..000000000 --- a/app/edge/views/edge-jobs/edgeJob/edgeJob.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
-
- - - - - - Configuration - - - - - - - - Results - - - - - - - -
-
diff --git a/app/edge/views/edge-jobs/edgeJob/edgeJobController.js b/app/edge/views/edge-jobs/edgeJob/edgeJobController.js deleted file mode 100644 index da24e2e24..000000000 --- a/app/edge/views/edge-jobs/edgeJob/edgeJobController.js +++ /dev/null @@ -1,192 +0,0 @@ -import _ from 'lodash-es'; -import { getEnvironments } from '@/react/portainer/environments/environment.service'; -import { confirmWebEditorDiscard } from '@@/modals/confirm'; - -export class EdgeJobController { - /* @ngInject */ - constructor($async, $q, $state, $window, EdgeJobService, FileSaver, GroupService, HostBrowserService, Notifications, TagService) { - this.state = { - actionInProgress: false, - showEditorTab: false, - isEditorDirty: false, - }; - - this.$async = $async; - this.$q = $q; - this.$state = $state; - this.$window = $window; - this.EdgeJobService = EdgeJobService; - this.FileSaver = FileSaver; - this.GroupService = GroupService; - this.HostBrowserService = HostBrowserService; - this.Notifications = Notifications; - this.TagService = TagService; - - this.update = this.update.bind(this); - this.updateAsync = this.updateAsync.bind(this); - this.downloadLogs = this.downloadLogs.bind(this); - this.downloadLogsAsync = this.downloadLogsAsync.bind(this); - this.collectLogs = this.collectLogs.bind(this); - this.collectLogsAsync = this.collectLogsAsync.bind(this); - this.clearLogs = this.clearLogs.bind(this); - this.clearLogsAsync = this.clearLogsAsync.bind(this); - this.refresh = this.refresh.bind(this); - this.refreshAsync = this.refreshAsync.bind(this); - this.showEditor = this.showEditor.bind(this); - } - - update() { - return this.$async(this.updateAsync); - } - - async updateAsync() { - const model = this.edgeJob; - this.state.actionInProgress = true; - - try { - await this.EdgeJobService.updateEdgeJob(model); - this.Notifications.success('Success', 'Edge job successfully updated'); - this.state.isEditorDirty = false; - this.$state.go('edge.jobs', {}, { reload: true }); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to update Edge job'); - } - - this.state.actionInProgress = false; - } - - downloadLogs(endpointId) { - return this.$async(this.downloadLogsAsync, endpointId); - } - async downloadLogsAsync(endpointId) { - try { - const data = await this.EdgeJobService.logFile(this.edgeJob.Id, endpointId); - const downloadData = new Blob([data.FileContent], { - type: 'text/plain;charset=utf-8', - }); - const logFileName = `job_${this.edgeJob.Id}_task_${endpointId}.log`; - this.FileSaver.saveAs(downloadData, logFileName); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to download file'); - } - } - - associateEndpointsToResults(results, endpoints) { - return _.map(results, (result) => { - const endpoint = _.find(endpoints, (endpoint) => endpoint.Id === result.EndpointId); - result.Endpoint = endpoint; - return result; - }); - } - - collectLogs(endpointId) { - return this.$async(this.collectLogsAsync, endpointId); - } - - async collectLogsAsync(endpointId) { - try { - await this.EdgeJobService.collectLogs(this.edgeJob.Id, endpointId); - this.results = this.results.map((result) => - result.EndpointId === endpointId - ? { - ...result, - LogsStatus: 2, - } - : result - ); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to collect logs'); - } - } - - clearLogs(endpointId) { - return this.$async(this.clearLogsAsync, endpointId); - } - async clearLogsAsync(endpointId) { - try { - await this.EdgeJobService.clearLogs(this.edgeJob.Id, endpointId); - this.results = this.results.map((result) => - result.EndpointId === endpointId - ? { - ...result, - LogsStatus: 1, - } - : result - ); - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to clear logs'); - } - } - - refresh() { - return this.$async(this.refreshAsync); - } - async refreshAsync() { - const { id } = this.$state.params; - const results = await this.EdgeJobService.jobResults(id); - if (results.length > 0) { - const endpointIds = _.map(results, (result) => result.EndpointId); - const endpoints = await getEnvironments({ query: { endpointIds } }); - this.results = this.associateEndpointsToResults(results, endpoints.value); - } else { - this.results = results; - } - } - - showEditor() { - this.state.showEditorTab = true; - } - - async uiCanExit() { - if (this.edgeJob && this.edgeJob.FileContent !== this.oldFileContent && this.state.isEditorDirty) { - return confirmWebEditorDiscard(); - } - } - - async $onInit() { - const { id, tab } = this.$state.params; - this.state.activeTab = tab; - if (!tab || tab === 0) { - this.state.showEditorTab = true; - } - - try { - const [edgeJob, file, results, groups, tags] = await Promise.all([ - this.EdgeJobService.edgeJob(id), - this.EdgeJobService.getScriptFile(id), - this.EdgeJobService.jobResults(id), - this.GroupService.groups(), - this.TagService.tags(), - ]); - - edgeJob.FileContent = file.FileContent; - this.oldFileContent = edgeJob.FileContent; - this.edgeJob = edgeJob; - this.groups = groups; - this.tags = tags; - - this.edgeJob.EdgeGroups = this.edgeJob.EdgeGroups ? this.edgeJob.EdgeGroups : []; - this.edgeJob.Endpoints = this.edgeJob.Endpoints ? this.edgeJob.Endpoints : []; - - if (results.length > 0) { - const endpointIds = _.map(results, (result) => result.EndpointId); - const endpoints = await getEnvironments({ query: { endpointIds } }); - this.results = this.associateEndpointsToResults(results, endpoints.value); - } else { - this.results = results; - } - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve environment list'); - } - - this.$window.onbeforeunload = () => { - if (this.edgeJob && this.edgeJob.FileContent !== this.oldFileContent && this.state.isEditorDirty) { - return ''; - } - }; - } - - $onDestroy() { - this.state.isEditorDirty = false; - } -} diff --git a/app/edge/views/edge-jobs/edgeJob/index.js b/app/edge/views/edge-jobs/edgeJob/index.js deleted file mode 100644 index d23dac592..000000000 --- a/app/edge/views/edge-jobs/edgeJob/index.js +++ /dev/null @@ -1,7 +0,0 @@ -import angular from 'angular'; -import { EdgeJobController } from './edgeJobController'; - -angular.module('portainer.edge').component('edgeJobView', { - templateUrl: './edgeJob.html', - controller: EdgeJobController, -}); diff --git a/app/react/components/NavTabs/NavTabs.tsx b/app/react/components/NavTabs/NavTabs.tsx index 4e1cf408b..8037953eb 100644 --- a/app/react/components/NavTabs/NavTabs.tsx +++ b/app/react/components/NavTabs/NavTabs.tsx @@ -75,8 +75,6 @@ export function NavTabs({ return; } - if (option.children) { - onSelect(option.id); - } + onSelect(option.id); } } diff --git a/app/react/edge/edge-jobs/.keep b/app/react/edge/edge-jobs/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx index 882928e9c..549df996f 100644 --- a/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx +++ b/app/react/edge/edge-jobs/CreateView/CreateEdgeJobForm.tsx @@ -1,6 +1,5 @@ import { Form, Formik, useFormikContext } from 'formik'; import { useRouter } from '@uirouter/react'; -import moment from 'moment'; import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; @@ -14,15 +13,19 @@ import { WebEditorForm } from '@@/WebEditorForm'; import { FileUploadForm } from '@@/form-components/FileUpload'; import { NameField } from '../components/EdgeJobForm/NameField'; -import { FormValues } from '../components/EdgeJobForm/types'; -import { useValidation } from '../components/EdgeJobForm/useValidation'; import { JobConfigurationFieldset } from '../components/EdgeJobForm/JobConfigurationFieldset'; import { BasePayload, CreateEdgeJobPayload, useCreateEdgeJobMutation, } from '../queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation'; -import { defaultCronExpression } from '../components/EdgeJobForm/RecurringFieldset'; +import { + toRecurringRequest, + toRecurringViewModel, +} from '../components/EdgeJobForm/parseRecurringValues'; + +import { FormValues } from './types'; +import { useValidation } from './useValidation'; export function CreateEdgeJobForm() { const mutation = useCreateEdgeJobMutation(); @@ -35,16 +38,12 @@ export function CreateEdgeJobForm() { validateOnMount initialValues={{ name: '', - recurring: false, - cronExpression: '', - recurringOption: defaultCronExpression, method: 'editor', - cronMethod: 'basic', - dateTime: new Date(), edgeGroupIds: [], environmentIds: [], file: undefined, fileContent: '', + ...toRecurringViewModel(), }} onSubmit={(values) => { mutation.mutate(getPayload(values.method, values), { @@ -122,6 +121,7 @@ function InnerForm({ isLoading }: { isLoading: boolean }) { isValid={isValid} data-cy="edgeJobCreate-addJobButton" loadingText="In progress..." + errors={errors} /> ); @@ -162,42 +162,7 @@ function getPayload( name: values.name, edgeGroups: values.edgeGroupIds, endpoints: values.environmentIds, - ...getRecurringConfig(values), + ...toRecurringRequest(values), }; } - - function getRecurringConfig(values: FormValues): { - recurring: boolean; - cronExpression: string; - } { - if (values.cronMethod !== 'basic') { - return { - recurring: true, - cronExpression: values.cronExpression, - }; - } - - if (values.recurring) { - return { - recurring: true, - cronExpression: values.recurringOption, - }; - } - - return { - recurring: false, - cronExpression: dateTimeToCron(values.dateTime), - }; - - function dateTimeToCron(datetime: Date) { - const date = moment(datetime); - return [ - date.minutes(), - date.hours(), - date.date(), - date.month() + 1, - '*', - ].join(' '); - } - } } diff --git a/app/react/edge/edge-jobs/CreateView/cron-method-options.tsx b/app/react/edge/edge-jobs/CreateView/cron-method-options.tsx deleted file mode 100644 index aa86d2798..000000000 --- a/app/react/edge/edge-jobs/CreateView/cron-method-options.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Calendar, Edit } from 'lucide-react'; - -import { BoxSelectorOption } from '@@/BoxSelector'; - -export const cronMethodOptions: ReadonlyArray> = [ - { - id: 'config_basic', - value: 'basic', - icon: Calendar, - iconType: 'badge', - label: 'Basic configuration', - description: 'Select date from calendar', - }, - { - id: 'config_advanced', - value: 'advanced', - icon: Edit, - iconType: 'badge', - label: 'Advanced configuration', - description: 'Write your own cron rule', - }, -] as const; diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/types.ts b/app/react/edge/edge-jobs/CreateView/types.ts similarity index 88% rename from app/react/edge/edge-jobs/components/EdgeJobForm/types.ts rename to app/react/edge/edge-jobs/CreateView/types.ts index 475c67c6a..ca3d37c9e 100644 --- a/app/react/edge/edge-jobs/components/EdgeJobForm/types.ts +++ b/app/react/edge/edge-jobs/CreateView/types.ts @@ -1,7 +1,7 @@ import { EdgeGroup } from '@/react/edge/edge-groups/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; -import { timeOptions } from './RecurringFieldset'; +import { timeOptions } from '../components/EdgeJobForm/RecurringFieldset'; export interface FormValues { name: string; diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/useValidation.ts b/app/react/edge/edge-jobs/CreateView/useValidation.ts similarity index 84% rename from app/react/edge/edge-jobs/components/EdgeJobForm/useValidation.ts rename to app/react/edge/edge-jobs/CreateView/useValidation.ts index 86e30acc0..1076a4611 100644 --- a/app/react/edge/edge-jobs/components/EdgeJobForm/useValidation.ts +++ b/app/react/edge/edge-jobs/CreateView/useValidation.ts @@ -12,17 +12,14 @@ import { useMemo } from 'react'; import { file } from '@@/form-components/yup-file-validation'; -import { EdgeJob } from '../../types'; +import { useNameValidation } from '../components/EdgeJobForm/NameField'; +import { cronValidation } from '../components/EdgeJobForm/AdvancedCronFieldset'; +import { timeOptions } from '../components/EdgeJobForm/RecurringFieldset'; import { FormValues } from './types'; -import { useNameValidation } from './NameField'; -import { cronValidation } from './AdvancedCronFieldset'; -import { timeOptions } from './RecurringFieldset'; -export function useValidation({ - id, -}: { id?: EdgeJob['Id'] } = {}): SchemaOf { - const nameValidation = useNameValidation(id); +export function useValidation(): SchemaOf { + const nameValidation = useNameValidation(); return useMemo( () => object({ diff --git a/app/react/edge/edge-jobs/ItemView/ItemView.tsx b/app/react/edge/edge-jobs/ItemView/ItemView.tsx new file mode 100644 index 000000000..c90523e58 --- /dev/null +++ b/app/react/edge/edge-jobs/ItemView/ItemView.tsx @@ -0,0 +1,75 @@ +import { ListIcon, WrenchIcon } from 'lucide-react'; + +import { useIdParam } from '@/react/hooks/useIdParam'; +import { useParamState } from '@/react/hooks/useParamState'; + +import { PageHeader } from '@@/PageHeader'; +import { Widget } from '@@/Widget'; +import { NavTabs } from '@@/NavTabs'; + +import { useEdgeJob } from '../queries/useEdgeJob'; + +import { UpdateEdgeJobForm } from './UpdateEdgeJobForm/UpdateEdgeJobForm'; +import { ResultsDatatable } from './ResultsDatatable/ResultsDatatable'; + +const tabs = [ + { + id: 0, + label: 'Configuration', + icon: WrenchIcon, + }, + { + id: 1, + label: 'Results', + icon: ListIcon, + }, +] as const; + +export function ItemView() { + const id = useIdParam(); + + const [tabId = 0, setTabId] = useParamState('tab', (param) => + param ? parseInt(param, 10) : 0 + ); + + const edgeJobQuery = useEdgeJob(id); + + if (!edgeJobQuery.data) { + return null; + } + + const edgeJob = edgeJobQuery.data; + + return ( + <> + + +
+
+ + + { + setTabId(id); + }} + options={tabs} + /> + + {tabId === tabs[0].id && } + + {tabId === tabs[1].id && ( +
+ +
+ )} +
+
+
+
+ + ); +} diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx index 14dd5c2ca..bacb19e62 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/ResultsDatatable.tsx @@ -1,67 +1,93 @@ import { List } from 'lucide-react'; -import { useEffect } from 'react'; +import { useMemo } from 'react'; -import { EnvironmentId } from '@/react/portainer/environments/types'; +import { Environment } from '@/react/portainer/environments/types'; +import { useEnvironmentList } from '@/react/portainer/environments/queries'; import { Datatable } from '@@/datatables'; import { useTableState } from '@@/datatables/useTableState'; import { withMeta } from '@@/datatables/extend-options/withMeta'; -import { useRepeater } from '@@/datatables/useRepeater'; -import { LogsStatus } from '../../types'; +import { EdgeJob, JobResult, LogsStatus } from '../../types'; +import { useJobResults } from '../../queries/jobResults/useJobResults'; -import { DecoratedJobResult } from './types'; import { columns } from './columns'; import { createStore } from './datatable-store'; const tableKey = 'edge-job-results'; const store = createStore(tableKey); -export function ResultsDatatable({ - dataset, - onCollectLogs, - onClearLogs, - onDownloadLogs, - onRefresh, -}: { - dataset: Array; +export function ResultsDatatable({ jobId }: { jobId: EdgeJob['Id'] }) { + const tableState = useTableState(store, tableKey); + + const jobResultsQuery = useJobResults(jobId, { + refetchInterval(dataset) { + const anyCollecting = dataset?.some( + (r) => r.LogsStatus === LogsStatus.Pending + ); + + if (anyCollecting) { + return 5000; + } + + return tableState.autoRefreshRate * 1000; + }, + }); - onCollectLogs(envId: EnvironmentId): void; - onDownloadLogs(envId: EnvironmentId): void; - onClearLogs(envId: EnvironmentId): void; - onRefresh(): void; -}) { - const anyCollecting = dataset.some( - (r) => r.LogsStatus === LogsStatus.Pending + const environmentIds = jobResultsQuery.data?.map( + (result) => result.EndpointId ); - const tableState = useTableState(store, tableKey); - const { setAutoRefreshRate } = tableState; + const environmentsQuery = useEnvironmentList( + { endpointIds: environmentIds }, + { enabled: !!environmentIds && !jobResultsQuery.isLoading } + ); - useEffect(() => { - setAutoRefreshRate(anyCollecting ? 5 : 0); - }, [anyCollecting, setAutoRefreshRate]); + const dataset = useMemo( + () => + jobResultsQuery.isLoading || environmentsQuery.isLoading + ? [] + : associateEndpointsToResults( + jobResultsQuery.data || [], + environmentsQuery.environments + ), + [ + environmentsQuery.environments, + environmentsQuery.isLoading, + jobResultsQuery.data, + jobResultsQuery.isLoading, + ] + ); - useRepeater(tableState.autoRefreshRate, onRefresh); return ( ); +} - function handleCollectLogs(envId: EnvironmentId) { - onCollectLogs(envId); - } +function associateEndpointsToResults( + results: Array, + environments: Array +) { + return results.map((result) => { + const environment = environments.find( + (environment) => environment.Id === result.EndpointId + ); + return { + ...result, + Endpoint: environment, + }; + }); } diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx index 36b22cdc8..08f554d7c 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/columns.tsx @@ -3,6 +3,9 @@ import { CellContext, createColumnHelper } from '@tanstack/react-table'; import { Button } from '@@/buttons'; import { LogsStatus } from '../../types'; +import { useDownloadLogsMutation } from '../../queries/jobResults/useDownloadLogsMutation'; +import { useClearLogsMutation } from '../../queries/jobResults/useClearLogsMutation'; +import { useCollectLogsMutation } from '../../queries/jobResults/useCollectLogsMutation'; import { DecoratedJobResult, getTableMeta } from './types'; @@ -29,6 +32,11 @@ function ActionsCell({ table, }: CellContext) { const tableMeta = getTableMeta(table.options.meta); + const id = tableMeta.jobId; + + const downloadLogsMutation = useDownloadLogsMutation(id); + const clearLogsMutations = useClearLogsMutation(id); + const collectLogsMutation = useCollectLogsMutation(id); switch (item.LogsStatus) { case LogsStatus.Pending: @@ -42,14 +50,14 @@ function ActionsCell({ return ( <> @@ -59,8 +67,8 @@ function ActionsCell({ default: return ( diff --git a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts index 911922ace..890dec4c2 100644 --- a/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts +++ b/app/react/edge/edge-jobs/ItemView/ResultsDatatable/types.ts @@ -1,19 +1,14 @@ -import { - Environment, - EnvironmentId, -} from '@/react/portainer/environments/types'; +import { Environment } from '@/react/portainer/environments/types'; -import { JobResult } from '../../types'; +import { EdgeJob, JobResult } from '../../types'; export interface DecoratedJobResult extends JobResult { - Endpoint: Environment; + Endpoint?: Environment; } interface TableMeta { table: 'edge-job-results'; - collectLogs(envId: EnvironmentId): void; - downloadLogs(envId: EnvironmentId): void; - clearLogs(envId: EnvironmentId): void; + jobId: EdgeJob['Id']; } function isTableMeta(meta: unknown): meta is TableMeta { diff --git a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx new file mode 100644 index 000000000..997363331 --- /dev/null +++ b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/UpdateEdgeJobForm.tsx @@ -0,0 +1,123 @@ +import { Form, Formik, useFormikContext } from 'formik'; +import { useRouter } from '@uirouter/react'; + +import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector'; +import { AssociatedEdgeEnvironmentsSelector } from '@/react/edge/components/AssociatedEdgeEnvironmentsSelector'; +import { notifySuccess } from '@/portainer/services/notifications'; + +import { FormActions } from '@@/form-components/FormActions'; +import { FormSection } from '@@/form-components/FormSection'; +import { WebEditorForm } from '@@/WebEditorForm'; + +import { NameField } from '../../components/EdgeJobForm/NameField'; +import { JobConfigurationFieldset } from '../../components/EdgeJobForm/JobConfigurationFieldset'; +import { + UpdatePayload, + useUpdateEdgeJobMutation, +} from '../../queries/useUpdateEdgeJobMutation'; +import { + toRecurringRequest, + toRecurringViewModel, +} from '../../components/EdgeJobForm/parseRecurringValues'; +import { EdgeJobResponse } from '../../queries/useEdgeJob'; +import { useEdgeJobFile } from '../../queries/useEdgeJobFile'; +import { useValidation } from '../useValidation'; + +import { FormValues } from './types'; + +export function UpdateEdgeJobForm({ edgeJob }: { edgeJob: EdgeJobResponse }) { + const fileQuery = useEdgeJobFile(edgeJob.Id); + const mutation = useUpdateEdgeJobMutation(); + const validation = useValidation({ id: edgeJob.Id }); + const router = useRouter(); + + if (!fileQuery.isSuccess) { + return null; + } + + return ( + + validationSchema={validation} + validateOnMount + initialValues={{ + name: edgeJob.Name, + + edgeGroupIds: edgeJob.EdgeGroups || [], + environmentIds: edgeJob.Endpoints || [], + fileContent: fileQuery.data, + ...toRecurringViewModel({ + cronExpression: edgeJob.CronExpression, + recurring: edgeJob.Recurring, + }), + }} + onSubmit={(values) => { + mutation.mutate( + { id: edgeJob.Id, payload: getPayload(values) }, + { + onSuccess: () => { + notifySuccess('Success', 'Edge job successfully updated'); + router.stateService.go('^'); + }, + } + ); + }} + > + + + ); +} + +function InnerForm({ isLoading }: { isLoading: boolean }) { + const { values, setFieldValue, isValid, errors } = + useFormikContext(); + + return ( +
+ + + + + setFieldValue('fileContent', value)} + value={values.fileContent} + placeholder="Define or paste the content of your script file here" + shell + error={errors.fileContent} + /> + + setFieldValue('edgeGroupIds', value)} + value={values.edgeGroupIds} + error={errors.edgeGroupIds} + /> + + + setFieldValue('environmentIds', value)} + value={values.environmentIds} + /> + + + + + ); +} + +function getPayload(values: FormValues): UpdatePayload { + return { + name: values.name, + edgeGroups: values.edgeGroupIds, + endpoints: values.environmentIds, + fileContent: values.fileContent, + ...toRecurringRequest(values), + }; +} diff --git a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/getPayload.tsx b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/getPayload.tsx new file mode 100644 index 000000000..96ebd593b --- /dev/null +++ b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/getPayload.tsx @@ -0,0 +1,14 @@ +import { UpdatePayload } from '../../queries/useUpdateEdgeJobMutation'; +import { toRecurringRequest } from '../../components/EdgeJobForm/parseRecurringValues'; + +import { FormValues } from './types'; + +export function getPayload(values: FormValues): UpdatePayload { + return { + name: values.name, + edgeGroups: values.edgeGroupIds, + endpoints: values.environmentIds, + fileContent: values.fileContent, + ...toRecurringRequest(values), + }; +} diff --git a/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/types.ts b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/types.ts new file mode 100644 index 000000000..e9135c04e --- /dev/null +++ b/app/react/edge/edge-jobs/ItemView/UpdateEdgeJobForm/types.ts @@ -0,0 +1,18 @@ +import { EdgeGroup } from '@/react/edge/edge-groups/types'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { timeOptions } from '../../components/EdgeJobForm/RecurringFieldset'; + +export interface FormValues { + name: string; + recurring: boolean; + edgeGroupIds: Array; + environmentIds: Array; + + fileContent: string; + + cronMethod: 'basic' | 'advanced'; + dateTime: Date; // basic !recurring + recurringOption: (typeof timeOptions)[number]['value']; // basic recurring + cronExpression: string; // advanced +} diff --git a/app/react/edge/edge-jobs/ItemView/useValidation.ts b/app/react/edge/edge-jobs/ItemView/useValidation.ts new file mode 100644 index 000000000..2472040b5 --- /dev/null +++ b/app/react/edge/edge-jobs/ItemView/useValidation.ts @@ -0,0 +1,60 @@ +import { + SchemaOf, + array, + boolean, + date, + mixed, + number, + object, + string, +} from 'yup'; +import { useMemo } from 'react'; + +import { EdgeJob } from '../types'; +import { useNameValidation } from '../components/EdgeJobForm/NameField'; +import { cronValidation } from '../components/EdgeJobForm/AdvancedCronFieldset'; +import { timeOptions } from '../components/EdgeJobForm/RecurringFieldset'; + +import { FormValues } from './UpdateEdgeJobForm/types'; + +export function useValidation({ + id, +}: { + id: EdgeJob['Id']; +}): SchemaOf { + const nameValidation = useNameValidation(id); + return useMemo( + () => + object({ + name: nameValidation, + recurring: boolean().default(false), + cronExpression: string().default('').when('cronMethod', { + is: 'advanced', + then: cronValidation().required(), + }), + edgeGroupIds: array(number().required()), + environmentIds: array(number().required()), + + fileContent: string().required('This field is required.'), + + cronMethod: mixed<'basic' | 'advanced'>() + .oneOf(['basic', 'advanced']) + .default('basic'), + dateTime: date() + .default(new Date()) + .when(['recurring', 'cronMethod'], { + is: (recurring: boolean, cronMethod: 'basic' | 'advanced') => + !recurring && cronMethod === 'basic', + then: (schema) => schema.required('This field is required.'), + }), + recurringOption: mixed() + .oneOf(timeOptions.map((o) => o.value)) + .when(['recurring', 'cronMethod'], { + is: (recurring: boolean, cronMethod: 'basic' | 'advanced') => + recurring && cronMethod === 'basic', + then: (schema) => schema.required('This field is required.'), + }), + }), + [nameValidation] + ); +} diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx index 5d5435420..ccbad21a5 100644 --- a/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/BasicCronFieldset.tsx @@ -2,7 +2,8 @@ import { useFormikContext } from 'formik'; import { SwitchField } from '@@/form-components/SwitchField'; -import { FormValues } from './types'; +import { FormValues } from '../../CreateView/types'; + import { RecurringFieldset, defaultCronExpression } from './RecurringFieldset'; import { ScheduledDateFieldset } from './ScheduledDateFieldset'; diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx b/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx index 7e82d1670..f8f1c76bd 100644 --- a/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/JobConfigurationFieldset.tsx @@ -1,14 +1,33 @@ import { useFormikContext } from 'formik'; +import { Calendar, Edit } from 'lucide-react'; import { FormSection } from '@@/form-components/FormSection'; -import { BoxSelector } from '@@/BoxSelector'; +import { BoxSelector, BoxSelectorOption } from '@@/BoxSelector'; -import { cronMethodOptions } from '../../CreateView/cron-method-options'; +import { FormValues } from '../../CreateView/types'; -import { FormValues } from './types'; import { AdvancedCronFieldset } from './AdvancedCronFieldset'; import { BasicCronFieldset } from './BasicCronFieldset'; +export const cronMethodOptions: ReadonlyArray> = [ + { + id: 'config_basic', + value: 'basic', + icon: Calendar, + iconType: 'badge', + label: 'Basic configuration', + description: 'Select date from calendar', + }, + { + id: 'config_advanced', + value: 'advanced', + icon: Edit, + iconType: 'badge', + label: 'Advanced configuration', + description: 'Write your own cron rule', + }, +] as const; + export function JobConfigurationFieldset() { const { values, setFieldValue } = useFormikContext(); diff --git a/app/react/edge/edge-jobs/components/EdgeJobForm/parseRecurringValues.ts b/app/react/edge/edge-jobs/components/EdgeJobForm/parseRecurringValues.ts new file mode 100644 index 000000000..91e3ddbcd --- /dev/null +++ b/app/react/edge/edge-jobs/components/EdgeJobForm/parseRecurringValues.ts @@ -0,0 +1,79 @@ +import { addHours, getDate, getHours, getMinutes, getMonth } from 'date-fns'; +import moment from 'moment'; + +import { defaultCronExpression, timeOptions } from './RecurringFieldset'; + +interface RecurringViewModel { + cronMethod: 'basic' | 'advanced'; + cronExpression: string; + recurring: boolean; + recurringOption: (typeof timeOptions)[number]['value']; + dateTime: Date; +} + +interface RecurringRequestModel { + recurring: boolean; + cronExpression: string; +} + +export function toRecurringRequest( + values: RecurringViewModel +): RecurringRequestModel { + if (values.cronMethod !== 'basic') { + return { + recurring: true, + cronExpression: values.cronExpression, + }; + } + + if (values.recurring) { + return { + recurring: true, + cronExpression: values.recurringOption, + }; + } + + return { + recurring: false, + cronExpression: dateTimeToCron(values.dateTime), + }; + + function dateTimeToCron(date: Date) { + return [ + getMinutes(date), + getHours(date), + getDate(date), + getMonth(date) + 1, + '*', + ].join(' '); + } +} + +export function toRecurringViewModel( + { cronExpression, recurring }: RecurringRequestModel = { + cronExpression: defaultCronExpression, + recurring: true, + } +): RecurringViewModel { + const defaultTime = addHours(new Date(), 1); + const scheduled = timeOptions.find((v) => v.value === cronExpression); + + return { + recurring, + cronExpression, + recurringOption: scheduled?.value || defaultCronExpression, + cronMethod: recurring && !scheduled ? 'advanced' : 'basic', + dateTime: cronExpression + ? cronToDateTime(cronExpression, defaultTime) + : defaultTime, + }; +} + +function cronToDateTime(cron: string, defaultTime: Date): Date { + const strings = cron.split(' '); + if (strings.length > 4) { + return moment(cron, 'm H D M').toDate(); + } + + return defaultTime; +} diff --git a/app/react/edge/edge-jobs/queries/jobResults/build-url.ts b/app/react/edge/edge-jobs/queries/jobResults/build-url.ts new file mode 100644 index 000000000..fc377b9d4 --- /dev/null +++ b/app/react/edge/edge-jobs/queries/jobResults/build-url.ts @@ -0,0 +1,22 @@ +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { EdgeJob } from '../../types'; +import { buildUrl as buildEdgeJobUrl } from '../build-url'; + +export function buildUrl({ + action, + id, + taskId, +}: { + id: EdgeJob['Id']; + action?: 'logs'; + taskId?: EnvironmentId; +}) { + const baseUrl = buildEdgeJobUrl({ id, action: 'tasks' }); + + if (taskId) { + return `${baseUrl}/${taskId}/${action}`; + } + + return baseUrl; +} diff --git a/app/react/edge/edge-jobs/queries/jobResults/query-keys.ts b/app/react/edge/edge-jobs/queries/jobResults/query-keys.ts new file mode 100644 index 000000000..2f56a1cf8 --- /dev/null +++ b/app/react/edge/edge-jobs/queries/jobResults/query-keys.ts @@ -0,0 +1,7 @@ +import { EdgeJob } from '../../types'; +import { queryKeys as edgeJobQueryKeys } from '../query-keys'; + +export const queryKeys = { + base: (id: EdgeJob['Id']) => + [...edgeJobQueryKeys.item(id), 'results'] as const, +}; diff --git a/app/react/edge/edge-jobs/queries/jobResults/useClearLogsMutation.ts b/app/react/edge/edge-jobs/queries/jobResults/useClearLogsMutation.ts new file mode 100644 index 000000000..1d52abe02 --- /dev/null +++ b/app/react/edge/edge-jobs/queries/jobResults/useClearLogsMutation.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withInvalidate } from '@/react-tools/react-query'; + +import { EdgeJob } from '../../types'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export function useClearLogsMutation(id: EdgeJob['Id']) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (environmentId: EnvironmentId) => + clearLogsMutation(id, environmentId), + ...withInvalidate(queryClient, [queryKeys.base(id)]), + }); +} + +async function clearLogsMutation( + id: EdgeJob['Id'], + environmentId: EnvironmentId +) { + try { + await axios.delete(buildUrl({ id, action: 'logs', taskId: environmentId })); + } catch (err) { + throw parseAxiosError(err, 'Failed clearing edge job result logs'); + } +} diff --git a/app/react/edge/edge-jobs/queries/jobResults/useCollectLogsMutation.ts b/app/react/edge/edge-jobs/queries/jobResults/useCollectLogsMutation.ts new file mode 100644 index 000000000..5c13a632e --- /dev/null +++ b/app/react/edge/edge-jobs/queries/jobResults/useCollectLogsMutation.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withInvalidate } from '@/react-tools/react-query'; + +import { EdgeJob } from '../../types'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export function useCollectLogsMutation(id: EdgeJob['Id']) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (environmentId: EnvironmentId) => + collectLogsMutation(id, environmentId), + ...withInvalidate(queryClient, [queryKeys.base(id)]), + }); +} + +async function collectLogsMutation( + id: EdgeJob['Id'], + environmentId: EnvironmentId +) { + try { + await axios.post(buildUrl({ id, action: 'logs', taskId: environmentId })); + } catch (err) { + throw parseAxiosError(err, 'Unable to collect logs'); + } +} diff --git a/app/react/edge/edge-jobs/queries/jobResults/useDownloadLogsMutation.ts b/app/react/edge/edge-jobs/queries/jobResults/useDownloadLogsMutation.ts new file mode 100644 index 000000000..15ad10b4d --- /dev/null +++ b/app/react/edge/edge-jobs/queries/jobResults/useDownloadLogsMutation.ts @@ -0,0 +1,39 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { saveAs } from 'file-saver'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withInvalidate } from '@/react-tools/react-query'; + +import { EdgeJob } from '../../types'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export function useDownloadLogsMutation(id: EdgeJob['Id']) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (environmentId: EnvironmentId) => + downloadLogsMutation(id, environmentId), + ...withInvalidate(queryClient, [queryKeys.base(id)]), + }); +} + +async function downloadLogsMutation( + id: EdgeJob['Id'], + environmentId: EnvironmentId +) { + try { + const { data } = await axios.get<{ FileContent: string }>( + buildUrl({ id, action: 'logs', taskId: environmentId }) + ); + const downloadData = new Blob([data.FileContent], { + type: 'text/plain;charset=utf-8', + }); + const logFileName = `job_${id}_task_${environmentId}.log`; + saveAs(downloadData, logFileName); + return data; + } catch (err) { + throw parseAxiosError(err, 'Unable to download file'); + } +} diff --git a/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts b/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts new file mode 100644 index 000000000..dcbe12129 --- /dev/null +++ b/app/react/edge/edge-jobs/queries/jobResults/useJobResults.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; + +import { EdgeJob, JobResult } from '../../types'; + +import { queryKeys } from './query-keys'; +import { buildUrl } from './build-url'; + +export function useJobResults( + id: EdgeJob['Id'], + { + refetchInterval, + }: { + refetchInterval?: + | number + | false + | ((data: Array | undefined) => number | false); + } = {} +) { + return useQuery({ + queryKey: queryKeys.base(id), + queryFn: () => getJobResults(id), + refetchInterval, + }); +} + +async function getJobResults(id: EdgeJob['Id']) { + try { + const { data } = await axios.get>(buildUrl({ id })); + + return data; + } catch (err) { + throw parseAxiosError(err, 'Failed fetching edge job results'); + } +} diff --git a/app/react/edge/edge-jobs/queries/query-keys.ts b/app/react/edge/edge-jobs/queries/query-keys.ts index 3b4b6f6c6..dd4a6c3d7 100644 --- a/app/react/edge/edge-jobs/queries/query-keys.ts +++ b/app/react/edge/edge-jobs/queries/query-keys.ts @@ -1,3 +1,7 @@ +import { EdgeJob } from '../types'; + export const queryKeys = { base: () => ['edge', 'jobs'] as const, + item: (id: EdgeJob['Id']) => [...queryKeys.base(), id] as const, + file: (id: EdgeJob['Id']) => [...queryKeys.item(id), 'file'] as const, }; diff --git a/app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation.ts b/app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation.ts index 1f9f9cf61..2b62b5c95 100644 --- a/app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation.ts +++ b/app/react/edge/edge-jobs/queries/useCreateEdgeJobMutation/useCreateEdgeJobMutation.ts @@ -1,13 +1,20 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { EdgeGroup } from '@/react/edge/edge-groups/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; +import { withInvalidate } from '@/react-tools/react-query'; + +import { queryKeys } from '../query-keys'; import { createJobFromFile } from './createJobFromFile'; import { createJobFromFileContent } from './createJobFromFileContent'; export function useCreateEdgeJobMutation() { - return useMutation(createEdgeJob); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: createEdgeJob, + ...withInvalidate(queryClient, [queryKeys.base()]), + }); } export type BasePayload = { diff --git a/app/react/edge/edge-jobs/queries/useEdgeJob.ts b/app/react/edge/edge-jobs/queries/useEdgeJob.ts new file mode 100644 index 000000000..458292e8a --- /dev/null +++ b/app/react/edge/edge-jobs/queries/useEdgeJob.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { EdgeJob } from '../types'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export interface EdgeJobResponse extends Omit { + Endpoints: Array | null; +} + +async function getEdgeJob(id: EdgeJobResponse['Id']) { + try { + const { data } = await axios.get(buildUrl({ id })); + return data; + } catch (err) { + throw parseAxiosError(err, 'Failed fetching edge job'); + } +} + +export function useEdgeJob( + id: EdgeJobResponse['Id'], + { + select, + }: { + select?: (job: EdgeJobResponse) => T; + } = {} +) { + return useQuery(queryKeys.item(id), () => getEdgeJob(id), { select }); +} diff --git a/app/react/edge/edge-jobs/queries/useEdgeJobFile.ts b/app/react/edge/edge-jobs/queries/useEdgeJobFile.ts new file mode 100644 index 000000000..bf7155dc8 --- /dev/null +++ b/app/react/edge/edge-jobs/queries/useEdgeJobFile.ts @@ -0,0 +1,28 @@ +import { useQuery } from '@tanstack/react-query'; + +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { EnvironmentId } from '@/react/portainer/environments/types'; + +import { EdgeJob } from '../types'; + +import { buildUrl } from './build-url'; +import { queryKeys } from './query-keys'; + +export interface EdgeJobResponse extends Omit { + Endpoints: Array; +} + +async function getEdgeJobFile(id: EdgeJobResponse['Id']) { + try { + const { data } = await axios.get<{ FileContent: string }>( + buildUrl({ id, action: 'file' }) + ); + return data.FileContent; + } catch (err) { + throw parseAxiosError(err, 'Failed fetching edge job file'); + } +} + +export function useEdgeJobFile(id: EdgeJobResponse['Id']) { + return useQuery(queryKeys.file(id), () => getEdgeJobFile(id)); +} diff --git a/app/react/edge/edge-jobs/queries/useUpdateEdgeJobMutation.tsx b/app/react/edge/edge-jobs/queries/useUpdateEdgeJobMutation.tsx new file mode 100644 index 000000000..5061d889d --- /dev/null +++ b/app/react/edge/edge-jobs/queries/useUpdateEdgeJobMutation.tsx @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { EnvironmentId } from '@/react/portainer/environments/types'; +import axios, { parseAxiosError } from '@/portainer/services/axios'; +import { withInvalidate } from '@/react-tools/react-query'; + +import { EdgeGroup } from '../../edge-groups/types'; + +import { queryKeys } from './query-keys'; + +export interface UpdatePayload { + name?: string; + cronExpression?: string; + recurring?: boolean; + endpoints?: Array; + edgeGroups?: Array; + fileContent?: string; +} + +export function useUpdateEdgeJobMutation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateEdgeJob, + ...withInvalidate(queryClient, [queryKeys.base()]), + }); +} + +async function updateEdgeJob({ + id, + payload, +}: { + id: number; + payload: UpdatePayload; +}) { + try { + await axios.put(`/edge_jobs/${id}`, payload); + } catch (error) { + throw parseAxiosError(error); + } +} diff --git a/app/react/edge/edge-jobs/types.ts b/app/react/edge/edge-jobs/types.ts index 7327f5cfc..e98333ddf 100644 --- a/app/react/edge/edge-jobs/types.ts +++ b/app/react/edge/edge-jobs/types.ts @@ -5,7 +5,7 @@ export interface EdgeJob { Created: number; CronExpression: string; Endpoints: Record; - EdgeGroups: number[]; + EdgeGroups: number[] | null; Name: string; ScriptPath: string; Recurring: boolean; diff --git a/app/react/hooks/useParamState.ts b/app/react/hooks/useParamState.ts index 98f122e10..f90b03914 100644 --- a/app/react/hooks/useParamState.ts +++ b/app/react/hooks/useParamState.ts @@ -14,7 +14,7 @@ export function useParamState( return [ state, (value?: T) => { - router.stateService.go('.', { [param]: value }, {}); + router.stateService.go('.', { [param]: value }, { reload: false }); }, ] as const; }