Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9ad6855
Fixes #39363 - Do not ask for host statuses when reading job invocati…
adamruzicka May 26, 2026
ece9180
Wait for previous poll response before scheduling the next one
adamruzicka May 26, 2026
9f73533
Fetch permissions only on initial load, not on every poll tick
adamruzicka May 26, 2026
6733116
Remove updateJob — its response was never read
adamruzicka May 26, 2026
bbcbbbe
Fix host table firing duplicate API calls on mount
adamruzicka May 26, 2026
861ceb2
Include cancellable in job invocation task, drop separate task fetch
adamruzicka May 26, 2026
aace775
Replace setInterval with setTimeout chain in TemplateInvocation polling
adamruzicka May 26, 2026
8061dce
Stop job invocation polling when the job finishes
adamruzicka May 26, 2026
6c43176
Drop dead autoRefresh logic — status_label already implies task state
adamruzicka Jul 2, 2026
b39d4ad
Fix include_permissions being dropped from initial job invocation fetch
adamruzicka Jul 2, 2026
b320191
Add tests for job invocation polling behaviour
adamruzicka Jul 2, 2026
7bdf7a0
Apply simplifications from code review
adamruzicka Jul 2, 2026
26ca032
Fix TemplateInvocation poll race and drop dead jobId params
adamruzicka Jul 2, 2026
8d13947
Make lints happy
adamruzicka Jul 2, 2026
7779ceb
Poll host table while job is running
adamruzicka Jul 3, 2026
1258caa
Refresh LIST_TEMPLATE_INVOCATIONS on page change and poll
adamruzicka Jul 3, 2026
41ed2d3
Include task and permissions in API hosts response
adamruzicka Jul 3, 2026
cd207a6
Drop list_jobs_hosts — replaced by API hosts endpoint
adamruzicka Jul 3, 2026
9bf9f05
Gate task and permissions in hosts response behind include_permission…
adamruzicka Jul 3, 2026
c5963e4
Pass jobFinished as prop to JobInvocationHostTable
adamruzicka Jul 3, 2026
f52f7bb
Simplify polling helpers in actions and host table
adamruzicka Jul 3, 2026
cb43e25
Fix polling cleanup inconsistencies and error toast fallbacks
adamruzicka Jul 3, 2026
f46dde2
Make lints happy
adamruzicka Jul 3, 2026
9677991
Use try(:cancellable?) to handle base ForemanTasks::Task in tests
adamruzicka Jul 3, 2026
2421ab3
Trim task data in hosts response to id and cancellable
adamruzicka Jul 3, 2026
96e0aae
Unify pollHostTable into makeApiCall
adamruzicka Jul 7, 2026
9f45cd3
Simplify error toast extraction and reuse task lookup in permissions
adamruzicka Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/controllers/api/v2/job_invocations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ def output
add_scoped_search_description_for(JobInvocation)
param :id, :identifier, :required => true
def hosts
@include_permissions = Foreman::Cast.to_bool(params[:include_permissions])
set_hosts_and_template_invocations
@total = @hosts.size
@hosts = @hosts.search_for(params[:search], :order => params[:order]).paginate(:page => params[:page], :per_page => params[:per_page])
Expand Down Expand Up @@ -329,6 +330,19 @@ def set_statuses_and_smart_proxies
end
@smart_proxy_id = template_invocations.to_h { |ti| [ti.host_id, ti.smart_proxy_id] }
@smart_proxy_name = template_invocations.to_h { |ti| [ti.host_id, ti.smart_proxy_name] }
return unless @include_permissions
@task_by_host = template_invocations.to_h do |ti|
task = ti.run_host_job_task
[ti.host_id, task]
end
@permissions_by_host = hosts.to_h do |host|
task = @task_by_host[host.id]
[host.id, {
:view_foreman_tasks => authorized_for(:permission => :view_foreman_tasks, :auth_object => task),
:cancel_job_invocations => authorized_for(:permission => :cancel_job_invocations, :auth_object => @job_invocation),
:execute_jobs => authorized_for(controller: :job_invocations, action: :create) && (!host.infrastructure_host? || User.current.can?(:execute_jobs_on_infrastructure_hosts)),
}]
end
Comment on lines +333 to +345

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not happy about this at all.

Alternatives would be:

  1. Having a ui-specific api-like controller
    1. as a completely separate class
    2. as a subclass of pi::V2::JobInvocationsController,only adding bits on top
  2. graphql

Querying two different controllers on every poll is out of the question.

end
end
end
Expand Down
29 changes: 1 addition & 28 deletions app/controllers/job_invocations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -149,33 +149,6 @@ def preview_job_invocations_per_host
render :json => {:job_invocations => job_invocations}
end

def list_jobs_hosts
@job_invocation = resource_base.find(params[:id])
hosts = @job_invocation.targeting.hosts.authorized(:view_hosts, Host)
hosts = hosts.search_for(params[:search])
template_invocations_task_by_hosts = {}
hosts.each do |host|
template_invocation = @job_invocation.template_invocations.find { |template_inv| template_inv.host_id == host.id }
next unless template_invocation
template_invocation_task = template_invocation.run_host_job_task
template_invocations_task_by_hosts[host.id] =
{
:host_name => host.name,
:id => host.id,
:task => template_invocation_task.attributes.merge({cancellable: template_invocation_task.cancellable? }),
:permissions => {
:view_foreman_tasks => authorized_for(:permission => :view_foreman_tasks, :auth_object => template_invocation_task),
:cancel_job_invocations => authorized_for(:permission => :cancel_job_invocations, :auth_object => @job_invocation),
:execute_jobs => authorized_for(controller: :job_invocations, action: :create) && (!host.infrastructure_host? || User.current.can?(:execute_jobs_on_infrastructure_hosts)),
},
}
end

render json: {
:template_invocations_task_by_hosts => template_invocations_task_by_hosts,
}
end

private

def action_permission
Expand All @@ -186,7 +159,7 @@ def action_permission
'create'
when 'cancel'
'cancel'
when 'chart', 'preview_job_invocations_per_host', 'list_jobs_hosts'
when 'chart', 'preview_job_invocations_per_host'
'view'
else
super
Expand Down
9 changes: 9 additions & 0 deletions app/views/api/v2/job_invocations/hosts.json.rabl
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,12 @@ end
node :smart_proxy_name do |host|
@smart_proxy_name[host.id]
end

node(:task, :if => ->(_) { @task_by_host }) do |host|
task = @task_by_host[host.id]
task && { :id => task.id, :cancellable => task.try(:cancellable?) }
end

node(:permissions, :if => ->(_) { @permissions_by_host }) do |host|
@permissions_by_host[host.id]
end
1 change: 1 addition & 0 deletions app/views/api/v2/job_invocations/main.json.rabl
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ end

child :task do
attributes :id, :state, :started_at
node(:cancellable) { |task| task.try(:cancellable?) }
end

if @template_invocations
Expand Down
1 change: 0 additions & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
get 'auto_complete_search'
end
member do
get 'hosts', to: 'job_invocations#list_jobs_hosts'
post 'cancel'
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/foreman_remote_execution/plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@
permission :lock_job_templates, { :job_templates => [:lock, :unlock] }, :resource_type => 'JobTemplate'
permission :create_job_invocations, { :job_invocations => [:new, :create, :legacy_create, :refresh, :rerun, :preview_hosts],
'api/v2/job_invocations' => [:create, :rerun] }, :resource_type => 'JobInvocation'
permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host, :list_jobs_hosts], :template_invocations => [:show, :show_template_invocation_by_host],
permission :view_job_invocations, { :job_invocations => [:index, :chart, :show, :auto_complete_search, :preview_job_invocations_per_host], :template_invocations => [:show, :show_template_invocation_by_host],
'api/v2/job_invocations' => [:index, :show, :output, :raw_output, :outputs, :hosts] }, :resource_type => 'JobInvocation'
permission :view_template_invocations, { :template_invocations => [:show, :template_invocation_preview, :show_template_invocation_by_host], :job_invocations => [:list_jobs_hosts],
permission :view_template_invocations, { :template_invocations => [:show, :template_invocation_preview, :show_template_invocation_by_host],
'api/v2/template_invocations' => [:template_invocations], :ui_job_wizard => [:job_invocation] }, :resource_type => 'TemplateInvocation'
permission :create_template_invocations, {}, :resource_type => 'TemplateInvocation'
permission :execute_jobs_on_infrastructure_hosts, {}, :resource_type => 'JobInvocation'
Expand Down
111 changes: 42 additions & 69 deletions webpack/JobInvocationDetail/JobInvocationActions.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,59 @@
import { translate as __, sprintf } from 'foremanReact/common/I18n';
import { foremanUrl } from 'foremanReact/common/helpers';
import { addToast } from 'foremanReact/components/ToastsList';
import { APIActions, get } from 'foremanReact/redux/API';
import {
stopInterval,
withInterval,
} from 'foremanReact/redux/middlewares/IntervalMiddleware';
import {
CANCEL_JOB,
CANCEL_RECURRING_LOGIC,
CHANGE_ENABLED_RECURRING_LOGIC,
GET_TASK,
JOB_INVOCATION_KEY,
UPDATE_JOB,
STATUS,
} from './JobInvocationConstants';

export const getJobInvocation = url => dispatch => {
const fetchData = withInterval(
let pollTimeoutId = null;

const FINISHED_STATUSES = [STATUS.FAILED, STATUS.SUCCEEDED, STATUS.CANCELLED];

export const isJobFinished = statusLabel =>
FINISHED_STATUSES.includes(statusLabel);

const extractErrorMessage = response =>
// eslint-disable-next-line camelcase
response?.data?.error?.full_messages?.[0] ||
response?.data?.error?.message ||
'Unknown error.';

const fetchJobInvocation = (dispatch, url, params = {}) => {
dispatch(
get({
key: JOB_INVOCATION_KEY,
params: { include_permissions: true, include_hosts: false },
params: { include_hosts: false, ...params },
url,
handleSuccess: ({ data }) => {
if (!isJobFinished(data.status_label)) {
pollTimeoutId = setTimeout(
() => fetchJobInvocation(dispatch, url),
5000
);
} else {
pollTimeoutId = null;
}
},
handleError: () => {
dispatch(stopInterval(JOB_INVOCATION_KEY));
pollTimeoutId = null;
},
errorToast: ({ response }) =>
// eslint-disable-next-line camelcase
response?.data?.error?.full_messages?.[0] ||
// eslint-disable-next-line camelcase
response?.data?.error?.full_messages ||
response?.data?.error?.message ||
'Error',
}),
1000
errorToast: ({ response }) => extractErrorMessage(response),
})
);
};

dispatch(fetchData);
export const getJobInvocation = url => dispatch => {
stopJobInvocationPolling();
fetchJobInvocation(dispatch, url, { include_permissions: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would be very careful about calling with inlcude_permissions only once. We can use it in each request, the difference between response with and without this information should not be more significant. After that we can simplify calls by removing this getJobInvocation. All informations should be already in the repeating calls.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the goals here was to reduce the number of times we re-load rarely changing information we already have.

We can use it in each request, the difference between response with and without this information should not be more significant.

The assumption was that the permissions shouldn't really change as the job runs, even though technically they can and recalculating those every second is a waste in majority of cases.

the difference between response with and without this information should not be more significant.

Yes, at the same time, it is yet another thing the backend has to compute.

With that being said, I don't feel strongly about this. We may find out that the savings coming from this are negligible and then we can very well favor the code simplicity over this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, I assume that when user privileges changes, or user logs out, server won't respond for this recurring api request, so there still will be permissions check on server side. So we can omit it on frontend.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

server won't respond for this recurring api request, so there still will be permissions check on server side

Backend always checks permissions, no matter what.

So we can omit it on frontend.

I was under the impression we load these so that we know which action elements can be shown as active. Yes, we could skip that, make everything appear to be clickable and let the backend reject what the user can't do, but then the user experience wouldn't be all that great.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we will check it in first request, it will render buttons correctly, so there won't be any issue, and if something changes (user log out, change permissions etc.) request will fail and user will be redirected or will have to reload, that will send new first request and receive updated privileges. So this should work fine.

};

export const updateJob = jobId => dispatch => {
const url = foremanUrl(`/api/job_invocations/${jobId}`);
dispatch(
APIActions.get({
url,
key: UPDATE_JOB,
params: { include_hosts: false },
})
);
export const stopJobInvocationPolling = () => {
clearTimeout(pollTimeoutId);
pollTimeoutId = null;
};

export const cancelJob = (jobId, force) => dispatch => {
Expand All @@ -66,13 +73,7 @@ export const cancelJob = (jobId, force) => dispatch => {
APIActions.post({
url,
key: CANCEL_JOB,
errorToast: ({ response }) =>
errorToast(
// eslint-disable-next-line camelcase
response?.data?.error?.full_messages ||
response?.data?.error?.message ||
'Unknown error.'
),
errorToast: ({ response }) => errorToast(extractErrorMessage(response)),
handleSuccess: () => {
dispatch(
addToast({
Expand All @@ -81,26 +82,12 @@ export const cancelJob = (jobId, force) => dispatch => {
message: infoToast(),
})
);
dispatch(updateJob(jobId));
},
})
);
};

export const getTask = taskId => dispatch => {
dispatch(
get({
key: GET_TASK,
url: `/foreman_tasks/api/tasks/${taskId}`,
})
);
};

export const enableRecurringLogic = (
recurrenceId,
enabled,
jobId
) => dispatch => {
export const enableRecurringLogic = (recurrenceId, enabled) => dispatch => {
const successToast = () =>
enabled
? sprintf(__('Recurring logic %s disabled successfully.'), recurrenceId)
Expand All @@ -122,19 +109,12 @@ export const enableRecurringLogic = (
key: CHANGE_ENABLED_RECURRING_LOGIC,
params: { recurring_logic: { enabled: !enabled } },
successToast,
errorToast: ({ response }) =>
errorToast(
// eslint-disable-next-line camelcase
response?.data?.error?.full_messages ||
response?.data?.error?.message ||
'Unknown error.'
),
handleSuccess: () => dispatch(updateJob(jobId)),
errorToast: ({ response }) => errorToast(extractErrorMessage(response)),
})
);
};

export const cancelRecurringLogic = (recurrenceId, jobId) => dispatch => {
export const cancelRecurringLogic = recurrenceId => dispatch => {
const successToast = () =>
sprintf(__('Recurring logic %s cancelled successfully.'), recurrenceId);
const errorToast = response =>
Expand All @@ -148,14 +128,7 @@ export const cancelRecurringLogic = (recurrenceId, jobId) => dispatch => {
url,
key: CANCEL_RECURRING_LOGIC,
successToast,
errorToast: ({ response }) =>
errorToast(
// eslint-disable-next-line camelcase
response?.data?.error?.full_messages ||
response?.data?.error?.message ||
'Unknown error.'
),
handleSuccess: () => dispatch(updateJob(jobId)),
errorToast: ({ response }) => errorToast(extractErrorMessage(response)),
})
);
};
5 changes: 0 additions & 5 deletions webpack/JobInvocationDetail/JobInvocationConstants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@ import JobStatusIcon from '../react_app/components/RecentJobsCard/JobStatusIcon'

export const JOB_INVOCATION_KEY = 'JOB_INVOCATION_KEY';
export const CURRENT_PERMISSIONS = 'CURRENT_PERMISSIONS';
export const UPDATE_JOB = 'UPDATE_JOB';
export const CANCEL_JOB = 'CANCEL_JOB';
export const GET_TASK = 'GET_TASK';
export const GET_TEMPLATE_INVOCATIONS = 'GET_TEMPLATE_INVOCATIONS';
export const CHANGE_ENABLED_RECURRING_LOGIC = 'CHANGE_ENABLED_RECURRING_LOGIC';
export const CANCEL_RECURRING_LOGIC = 'CANCEL_RECURRING_LOGIC';
Expand All @@ -18,16 +16,13 @@ export const GET_REPORT_TEMPLATE_INPUTS = 'GET_REPORT_TEMPLATE_INPUTS';
export const JOB_INVOCATION_HOSTS = 'JOB_INVOCATION_HOSTS';
export const GET_TEMPLATE_INVOCATION = 'GET_TEMPLATE_INVOCATION';
export const DIRECT_OPEN_HOST_LIMIT = 3;
export const ALL_JOB_HOSTS = 'ALL_JOB_HOSTS';
export const AWAITING_STATUS_FILTER = '(job_invocation.result = N/A)';
export const currentPermissionsUrl = foremanUrl(
'/api/v2/permissions/current_permissions'
);

export const showTemplateInvocationUrl = (hostID, jobID) =>
`/show_template_invocation_by_host/${hostID}/job_invocation/${jobID}`;
export const LIST_TEMPLATE_INVOCATIONS = 'LIST_TEMPLATE_INVOCATIONS';

export const templateInvocationPageUrl = (hostID, jobID) =>
`/job_invocations_detail/${jobID}/host_invocation/${hostID}`;

Expand Down
Loading
Loading