Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
baf6c31
Wait for previous poll response before scheduling the next one
adamruzicka May 26, 2026
68026f8
Fetch permissions only on initial load, not on every poll tick
adamruzicka May 26, 2026
068afa5
Remove updateJob — its response was never read
adamruzicka May 26, 2026
b2ec8c2
Fix host table firing duplicate API calls on mount
adamruzicka May 26, 2026
d3ab2a9
Include cancellable in job invocation task, drop separate task fetch
adamruzicka May 26, 2026
46bd375
Replace setInterval with setTimeout chain in TemplateInvocation polling
adamruzicka May 26, 2026
951c03d
Stop job invocation polling when the job finishes
adamruzicka May 26, 2026
26252f3
Drop dead autoRefresh logic — status_label already implies task state
adamruzicka Jul 2, 2026
3ac94d5
Fix include_permissions being dropped from initial job invocation fetch
adamruzicka Jul 2, 2026
8ffd523
Add tests for job invocation polling behaviour
adamruzicka Jul 2, 2026
21b9c7a
Apply simplifications from code review
adamruzicka Jul 2, 2026
4e09702
Fix TemplateInvocation poll race and drop dead jobId params
adamruzicka Jul 2, 2026
4cc5429
Make lints happy
adamruzicka Jul 2, 2026
a698e44
Poll host table while job is running
adamruzicka Jul 3, 2026
161c5d0
Refresh LIST_TEMPLATE_INVOCATIONS on page change and poll
adamruzicka Jul 3, 2026
6676267
Include task and permissions in API hosts response
adamruzicka Jul 3, 2026
491fc93
Drop list_jobs_hosts — replaced by API hosts endpoint
adamruzicka Jul 3, 2026
fa93cc0
Gate task and permissions in hosts response behind include_permission…
adamruzicka Jul 3, 2026
9615860
Pass jobFinished as prop to JobInvocationHostTable
adamruzicka Jul 3, 2026
32e7dd7
Simplify polling helpers in actions and host table
adamruzicka Jul 3, 2026
7f18a92
Fix polling cleanup inconsistencies and error toast fallbacks
adamruzicka Jul 3, 2026
48a1892
Make lints happy
adamruzicka Jul 3, 2026
945c0f0
Use try(:cancellable?) to handle base ForemanTasks::Task in tests
adamruzicka Jul 3, 2026
e7ffafc
Trim task data in hosts response to id and cancellable
adamruzicka Jul 3, 2026
0b692d9
Unify pollHostTable into makeApiCall
adamruzicka Jul 7, 2026
24dd1cf
Simplify error toast extraction and reuse task lookup in permissions
adamruzicka Jul 7, 2026
43842bf
Replace redux-mock-store with a real Redux store in polling tests
adamruzicka Jul 10, 2026
9a5008c
Add polling test coverage for TemplateInvocation and JobInvocationHos…
adamruzicka Jul 10, 2026
c701ec4
Fix ESLint failures in webpack test and source files
adamruzicka Jul 10, 2026
b8dd1d3
Fix host table staleness on id change and redundant fetches for finis…
adamruzicka Jul 10, 2026
9b0664d
Use sprintf placeholders for error response in cancelJob toast
adamruzicka Jul 10, 2026
f63e877
Only fetch permissions on first load, not on every poll
adamruzicka Jul 10, 2026
5ee41fe
Hoist host-independent permission checks out of the per-host loop
adamruzicka Jul 13, 2026
022c4fa
Replace module-level pollTimeoutId with useRef in JobInvocationActions
adamruzicka Jul 13, 2026
5bd334d
Add tests and fix nil task in authorized_for for hosts with include_p…
adamruzicka Jul 13, 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
17 changes: 17 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,22 @@ 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
can_cancel_job_invocations = authorized_for(:permission => :cancel_job_invocations, :auth_object => @job_invocation)
can_create_job_invocations = authorized_for(controller: :job_invocations, action: :create)
can_execute_on_infra_hosts = User.current.can?(:execute_jobs_on_infrastructure_hosts)
@permissions_by_host = hosts.to_h do |host|
task = @task_by_host[host.id]
[host.id, {
:view_foreman_tasks => task && authorized_for(:permission => :view_foreman_tasks, :auth_object => task),
:cancel_job_invocations => can_cancel_job_invocations,
:execute_jobs => can_create_job_invocations && (!host.infrastructure_host? || can_execute_on_infra_hosts),
}]
end
Comment on lines +333 to +348

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
68 changes: 62 additions & 6 deletions test/functional/api/v2/job_invocations_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ class JobInvocationsControllerTest < ActionController::TestCase
assert_nil result['template_invocations']
end

test 'should include cancellable field in task node' do
get :show, params: { :id => @invocation.id }
assert_response :success
result = ActiveSupport::JSON.decode(@response.body)
assert result.key?('task'), 'task node should be present in show response'
assert result['task'].key?('cancellable'), 'cancellable field should be present in task node'
end

test 'should include job_status per host when host_status=true' do
invocation = FactoryBot.create(:job_invocation, :with_template, :with_task)
invocation.template_invocations << FactoryBot.create(:template_invocation, :with_task, :with_host, :job_invocation => invocation)
Expand Down Expand Up @@ -284,19 +292,67 @@ class JobInvocationsControllerTest < ActionController::TestCase
end

describe '#hosts' do
setup do
@hosts_invocation = FactoryBot.create(:job_invocation, :with_template, :with_task)
2.times { @hosts_invocation.template_invocations << FactoryBot.create(:template_invocation, :with_task, :with_host, :job_invocation => @hosts_invocation) }
@hosts_invocation.job_category = @hosts_invocation.pattern_template_invocations.first.template.job_category
@hosts_invocation.targeting.hosts = @hosts_invocation.template_invocations.map(&:host)
@hosts_invocation.save!
end

test 'should compute job_status for the paginated subset' do
get :hosts, params: { :id => @hosts_invocation.id, :page => 2, :per_page => 1 }
assert_response :success
result = ActiveSupport::JSON.decode(@response.body)
assert_equal 3, result['total']
assert_equal 1, result['results'].size
assert_equal(['success'], result['results'].map { |r| r['job_status'] })
end

test 'should not include task or permissions nodes without include_permissions' do
get :hosts, params: { :id => @hosts_invocation.id }
assert_response :success
result = ActiveSupport::JSON.decode(@response.body)
assert_not_empty result['results']
result['results'].each do |host|
assert_not host.key?('task'), 'task node should be absent when include_permissions is not set'
assert_not host.key?('permissions'), 'permissions node should be absent when include_permissions is not set'
end
end

test 'should include task and permissions nodes when include_permissions=true' do
get :hosts, params: { :id => @hosts_invocation.id, :include_permissions => true }
assert_response :success
result = ActiveSupport::JSON.decode(@response.body)
assert_not_empty result['results']
result['results'].each do |host|
assert host.key?('task'), 'task node should be present when include_permissions=true'
assert host.key?('permissions'), 'permissions node should be present when include_permissions=true'
assert host['task'].key?('id')
assert host['task'].key?('cancellable')
assert host['permissions'].key?('view_foreman_tasks')
assert host['permissions'].key?('cancel_job_invocations')
assert host['permissions'].key?('execute_jobs')
end
end

test 'should handle nil run_host_job_task with include_permissions=true' do
# Create a host without a run_host_job_task (template invocation without :with_task)
invocation = FactoryBot.create(:job_invocation, :with_template, :with_task)
2.times { invocation.template_invocations << FactoryBot.create(:template_invocation, :with_task, :with_host, :job_invocation => invocation) }
ti = FactoryBot.create(:template_invocation, :with_host, :job_invocation => invocation)
invocation.job_category = invocation.pattern_template_invocations.first.template.job_category
invocation.targeting.hosts = invocation.template_invocations.map(&:host)
invocation.targeting.hosts = [ti.host]
invocation.save!

get :hosts, params: { :id => invocation.id, :page => 2, :per_page => 1 }
get :hosts, params: { :id => invocation.id, :include_permissions => true }
assert_response :success
result = ActiveSupport::JSON.decode(@response.body)
assert_equal 3, result['total']
assert_equal 1, result['results'].size
assert_equal(['success'], result['results'].map { |r| r['job_status'] })
assert_not_empty result['results']
host_result = result['results'].first
assert host_result.key?('task'), 'task node should be present'
assert_nil host_result['task'], 'task should be nil when run_host_job_task is absent'
assert host_result.key?('permissions'), 'permissions node should be present'
refute host_result['permissions']['view_foreman_tasks']
end
end

Expand Down
110 changes: 44 additions & 66 deletions webpack/JobInvocationDetail/JobInvocationActions.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,57 @@
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(
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 = {}, pollTimeoutRef = { current: null }) => {

Check failure on line 23 in webpack/JobInvocationDetail/JobInvocationActions.js

View workflow job for this annotation

GitHub Actions / JavaScript / Foreman develop Ruby 3.0 and Node 22

Replace `dispatch,·url,·params·=·{},·pollTimeoutRef·=·{·current:·null·}` with `⏎··dispatch,⏎··url,⏎··params·=·{},⏎··pollTimeoutRef·=·{·current:·null·}⏎`
dispatch(
get({
key: JOB_INVOCATION_KEY,
params: { include_permissions: true, include_hosts: false },
params: { include_hosts: false, include_permissions: true, ...params },
url,
handleSuccess: ({ data }) => {
if (!isJobFinished(data.status_label)) {
pollTimeoutRef.current = setTimeout(
() => fetchJobInvocation(dispatch, url, {}, pollTimeoutRef),
5000

Check failure on line 33 in webpack/JobInvocationDetail/JobInvocationActions.js

View workflow job for this annotation

GitHub Actions / JavaScript / Foreman develop Ruby 3.0 and Node 22

No magic number: 5000
);
} else {
pollTimeoutRef.current = null;
}
},
handleError: () => {
dispatch(stopInterval(JOB_INVOCATION_KEY));
pollTimeoutRef.current = 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, pollTimeoutRef) => dispatch => {
stopJobInvocationPolling(pollTimeoutRef);
fetchJobInvocation(dispatch, url, { include_permissions: true }, pollTimeoutRef);

Check failure on line 49 in webpack/JobInvocationDetail/JobInvocationActions.js

View workflow job for this annotation

GitHub Actions / JavaScript / Foreman develop Ruby 3.0 and Node 22

Replace `dispatch,·url,·{·include_permissions:·true·},·pollTimeoutRef` with `⏎····dispatch,⏎····url,⏎····{·include_permissions:·true·},⏎····pollTimeoutRef⏎··`
};

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 = pollTimeoutRef => {
clearTimeout(pollTimeoutRef.current);
pollTimeoutRef.current = null;
};

export const cancelJob = (jobId, force) => dispatch => {
Expand All @@ -56,8 +61,8 @@
: sprintf(__('Trying to cancel the job %s.'), jobId);
const errorToast = response =>
force
? sprintf(__(`Could not abort the job %s: ${response}`), jobId)
: sprintf(__(`Could not cancel the job %s: ${response}`), jobId);
? sprintf(__('Could not abort the job %s: %s'), jobId, response)
: sprintf(__('Could not cancel the job %s: %s'), jobId, response);
const url = force
? `/job_invocations/${jobId}/cancel?force=true`
: `/job_invocations/${jobId}/cancel`;
Expand All @@ -66,13 +71,7 @@
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,21 +80,11 @@
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,
Expand All @@ -122,19 +111,15 @@
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)),
handleSuccess: () => {
fetchJobInvocation(dispatch, `/api/job_invocations/${jobId}`);
},
})
);
};

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 +133,7 @@
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)),
})
);
};
Loading
Loading