foreman_openbolt 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +190 -19
- data/Rakefile +17 -93
- data/app/controllers/foreman_openbolt/task_controller.rb +61 -49
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +11 -10
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +70 -60
- data/app/models/foreman_openbolt/task_job.rb +16 -17
- data/config/routes.rb +0 -1
- data/lib/foreman_openbolt/engine.rb +11 -11
- data/lib/foreman_openbolt/version.rb +1 -1
- data/lib/proxy_api/openbolt.rb +25 -9
- data/lib/tasks/foreman_openbolt_tasks.rake +1 -22
- data/locale/gemspec.rb +1 -1
- data/package.json +11 -15
- data/test/acceptance/acceptance_helper.rb +146 -0
- data/test/acceptance/docker/docker-compose.yml +69 -0
- data/test/acceptance/docker/foreman/Dockerfile +45 -0
- data/test/acceptance/docker/foreman/entrypoint.sh +26 -0
- data/test/acceptance/docker/target/Dockerfile +29 -0
- data/test/acceptance/docker/target/entrypoint.sh +11 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.json +30 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/complex_params.sh +16 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/echo.json +13 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/echo.sh +3 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.json +8 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/failing_task.sh +3 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.json +8 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/noop_task.sh +2 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.json +14 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/slow_task.sh +3 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.json +13 -0
- data/test/acceptance/fixtures/modules/acceptance/tasks/target_conditional.sh +9 -0
- data/test/acceptance/fixtures/openbolt.yml +7 -0
- data/test/acceptance/tests/error_handling_test.rb +40 -0
- data/test/acceptance/tests/host_selector_test.rb +31 -0
- data/test/acceptance/tests/launch_task_test.rb +96 -0
- data/test/acceptance/tests/parameter_table_test.rb +61 -0
- data/test/acceptance/tests/settings_test.rb +95 -0
- data/test/acceptance/tests/ssh_options_test.rb +77 -0
- data/test/acceptance/tests/task_execution_test.rb +40 -0
- data/test/acceptance/tests/task_history_test.rb +84 -0
- data/test/acceptance/tests/transport_options_test.rb +121 -0
- data/test/test_plugin_helper.rb +12 -3
- data/test/unit/controllers/task_controller_test.rb +351 -0
- data/test/unit/docker/Dockerfile +47 -0
- data/test/unit/docker/docker-compose.yml +33 -0
- data/test/unit/docker/entrypoint.sh +4 -0
- data/test/unit/factories/foreman_openbolt_factories.rb +39 -0
- data/test/unit/lib/actions/cleanup_proxy_artifacts_test.rb +51 -0
- data/test/unit/lib/actions/poll_task_status_test.rb +141 -0
- data/test/unit/lib/proxy_api/openbolt_test.rb +174 -0
- data/test/unit/models/task_job_test.rb +278 -0
- data/webpack/__mocks__/foremanReact/common/I18n.js +15 -0
- data/webpack/__mocks__/foremanReact/components/ToastsList/index.js +6 -0
- data/webpack/__mocks__/foremanReact/redux/API/index.js +11 -0
- data/webpack/src/Components/LaunchTask/FieldTable.js +8 -5
- data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +74 -62
- data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +11 -13
- data/webpack/src/Components/LaunchTask/HostSelector/index.js +28 -33
- data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +3 -2
- data/webpack/src/Components/LaunchTask/ParameterField.js +2 -0
- data/webpack/src/Components/LaunchTask/SmartProxySelect.js +2 -1
- data/webpack/src/Components/LaunchTask/TaskSelect.js +3 -3
- data/webpack/src/Components/LaunchTask/__tests__/EmptyContent.test.js +10 -0
- data/webpack/src/Components/LaunchTask/__tests__/LaunchTask.test.js +83 -0
- data/webpack/src/Components/LaunchTask/__tests__/ParameterField.test.js +86 -0
- data/webpack/src/Components/LaunchTask/__tests__/ParametersSection.test.js +50 -0
- data/webpack/src/Components/LaunchTask/__tests__/SmartProxySelect.test.js +63 -0
- data/webpack/src/Components/LaunchTask/__tests__/TaskSelect.test.js +39 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useOpenBoltOptions.test.js +90 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useSmartProxies.test.js +69 -0
- data/webpack/src/Components/LaunchTask/hooks/__tests__/useTasksData.test.js +103 -0
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +9 -11
- data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +12 -13
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +6 -13
- data/webpack/src/Components/LaunchTask/index.js +9 -27
- data/webpack/src/Components/TaskExecution/ExecutionDetails.js +29 -29
- data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +9 -10
- data/webpack/src/Components/TaskExecution/LoadingIndicator.js +7 -2
- data/webpack/src/Components/TaskExecution/ResultDisplay.js +13 -17
- data/webpack/src/Components/TaskExecution/TaskDetails.js +58 -67
- data/webpack/src/Components/TaskExecution/__tests__/ExecutionDetails.test.js +47 -0
- data/webpack/src/Components/TaskExecution/__tests__/ExecutionDisplay.test.js +29 -0
- data/webpack/src/Components/TaskExecution/__tests__/LoadingIndicator.test.js +25 -0
- data/webpack/src/Components/TaskExecution/__tests__/ResultDisplay.test.js +28 -0
- data/webpack/src/Components/TaskExecution/__tests__/TaskDetails.test.js +38 -0
- data/webpack/src/Components/TaskExecution/__tests__/TaskExecution.test.js +80 -0
- data/webpack/src/Components/TaskExecution/hooks/__tests__/useJobPolling.test.js +177 -0
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +34 -33
- data/webpack/src/Components/TaskExecution/index.js +10 -12
- data/webpack/src/Components/TaskHistory/TaskPopover.js +9 -12
- data/webpack/src/Components/TaskHistory/__tests__/TaskHistory.test.js +109 -0
- data/webpack/src/Components/TaskHistory/__tests__/TaskPopover.test.js +26 -0
- data/webpack/src/Components/TaskHistory/index.js +21 -29
- data/webpack/src/Components/common/HostsPopover.js +12 -3
- data/webpack/src/Components/common/__tests__/HostsPopover.test.js +20 -0
- data/webpack/src/Components/common/__tests__/helpers.test.js +135 -0
- data/webpack/src/Components/common/helpers.js +34 -5
- data/webpack/test_setup.js +34 -11
- metadata +65 -87
- data/test/factories/foreman_openbolt_factories.rb +0 -7
- data/test/unit/foreman_openbolt_test.rb +0 -13
- data/webpack/global_test_setup.js +0 -11
- data/webpack/webpack.config.js +0 -7
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react-hooks';
|
|
2
|
+
import { API } from 'foremanReact/redux/API';
|
|
3
|
+
import useJobPolling from '../useJobPolling';
|
|
4
|
+
|
|
5
|
+
jest.useFakeTimers();
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
jest.clearAllMocks();
|
|
9
|
+
jest.clearAllTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('useJobPolling', () => {
|
|
13
|
+
test('returns undefined state when jobId is null', () => {
|
|
14
|
+
const { result } = renderHook(() => useJobPolling(null));
|
|
15
|
+
expect(result.current.status).toBe('pending');
|
|
16
|
+
expect(result.current.isPolling).toBe(false);
|
|
17
|
+
expect(result.current.result).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('polls for status and completes when job succeeds', async () => {
|
|
21
|
+
const statusData = {
|
|
22
|
+
status: 'success',
|
|
23
|
+
submitted_at: '2026-01-01T00:00:00Z',
|
|
24
|
+
completed_at: '2026-01-01T00:01:00Z',
|
|
25
|
+
task_name: 'test::task',
|
|
26
|
+
task_description: 'A test task',
|
|
27
|
+
task_parameters: { name: 'nginx' },
|
|
28
|
+
targets: ['host1.example.com'],
|
|
29
|
+
smart_proxy: { id: 1, name: 'proxy1' },
|
|
30
|
+
};
|
|
31
|
+
const resultData = {
|
|
32
|
+
command: 'bolt task run test::task',
|
|
33
|
+
value: { items: [{ status: 'success' }] },
|
|
34
|
+
log: 'Task completed',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
API.get
|
|
38
|
+
.mockResolvedValueOnce({ data: statusData })
|
|
39
|
+
.mockResolvedValueOnce({ data: resultData });
|
|
40
|
+
|
|
41
|
+
let hookResult;
|
|
42
|
+
await act(async () => {
|
|
43
|
+
hookResult = renderHook(() => useJobPolling('job-123'));
|
|
44
|
+
// Let the poll promise resolve
|
|
45
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { result } = hookResult;
|
|
49
|
+
expect(result.current.status).toBe('success');
|
|
50
|
+
expect(result.current.taskName).toBe('test::task');
|
|
51
|
+
expect(result.current.targets).toEqual(['host1.example.com']);
|
|
52
|
+
expect(result.current.result).toEqual({
|
|
53
|
+
command: 'bolt task run test::task',
|
|
54
|
+
result: { items: [{ status: 'success' }] },
|
|
55
|
+
log: 'Task completed',
|
|
56
|
+
});
|
|
57
|
+
expect(result.current.isPolling).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('does not fetch result for INVALID status', async () => {
|
|
61
|
+
const statusData = {
|
|
62
|
+
status: 'invalid',
|
|
63
|
+
task_name: 'broken::task',
|
|
64
|
+
targets: [],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
API.get.mockResolvedValueOnce({ data: statusData });
|
|
68
|
+
|
|
69
|
+
let hookResult;
|
|
70
|
+
await act(async () => {
|
|
71
|
+
hookResult = renderHook(() => useJobPolling('job-456'));
|
|
72
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const { result } = hookResult;
|
|
76
|
+
expect(result.current.status).toBe('invalid');
|
|
77
|
+
expect(result.current.result).toBeNull();
|
|
78
|
+
// Only one API call (status), no result fetch
|
|
79
|
+
expect(API.get).toHaveBeenCalledTimes(1);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('sets error when status endpoint fails', async () => {
|
|
83
|
+
API.get.mockRejectedValueOnce({ message: 'Connection refused' });
|
|
84
|
+
|
|
85
|
+
let hookResult;
|
|
86
|
+
await act(async () => {
|
|
87
|
+
hookResult = renderHook(() => useJobPolling('job-789'));
|
|
88
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { result } = hookResult;
|
|
92
|
+
expect(result.current.error).toContain('Connection refused');
|
|
93
|
+
expect(result.current.isPolling).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('handles result fetch failure gracefully', async () => {
|
|
97
|
+
const statusData = {
|
|
98
|
+
status: 'success',
|
|
99
|
+
task_name: 'test::task',
|
|
100
|
+
targets: [],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
API.get
|
|
104
|
+
.mockResolvedValueOnce({ data: statusData })
|
|
105
|
+
.mockRejectedValueOnce({ message: 'Result unavailable' });
|
|
106
|
+
|
|
107
|
+
let hookResult;
|
|
108
|
+
await act(async () => {
|
|
109
|
+
hookResult = renderHook(() => useJobPolling('job-fail'));
|
|
110
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const { result } = hookResult;
|
|
114
|
+
expect(result.current.status).toBe('success');
|
|
115
|
+
expect(result.current.error).toContain('Result unavailable');
|
|
116
|
+
expect(result.current.result).toEqual({ result: null, log: '' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('cancels polling on unmount', async () => {
|
|
120
|
+
const statusData = {
|
|
121
|
+
status: 'running',
|
|
122
|
+
task_name: 'long::task',
|
|
123
|
+
targets: [],
|
|
124
|
+
};
|
|
125
|
+
API.get.mockResolvedValue({ data: statusData });
|
|
126
|
+
|
|
127
|
+
let hookResult;
|
|
128
|
+
await act(async () => {
|
|
129
|
+
hookResult = renderHook(() => useJobPolling('job-cancel'));
|
|
130
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Unmount while still polling
|
|
134
|
+
hookResult.unmount();
|
|
135
|
+
|
|
136
|
+
// Advance timers past the polling interval
|
|
137
|
+
jest.advanceTimersByTime(10000);
|
|
138
|
+
|
|
139
|
+
// API should have been called once for the initial poll, not more
|
|
140
|
+
const callCount = API.get.mock.calls.length;
|
|
141
|
+
jest.advanceTimersByTime(10000);
|
|
142
|
+
expect(API.get.mock.calls).toHaveLength(callCount);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('loads metadata only once across multiple polls', async () => {
|
|
146
|
+
API.get
|
|
147
|
+
.mockResolvedValueOnce({
|
|
148
|
+
data: { status: 'running', task_name: 'my::task', targets: ['host1'] },
|
|
149
|
+
})
|
|
150
|
+
.mockResolvedValueOnce({
|
|
151
|
+
data: {
|
|
152
|
+
status: 'success',
|
|
153
|
+
task_name: 'changed::name',
|
|
154
|
+
targets: ['host2'],
|
|
155
|
+
},
|
|
156
|
+
})
|
|
157
|
+
.mockResolvedValueOnce({
|
|
158
|
+
data: { command: 'bolt', value: {}, log: '' },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
let hookResult;
|
|
162
|
+
await act(async () => {
|
|
163
|
+
hookResult = renderHook(() => useJobPolling('job-meta'));
|
|
164
|
+
// First poll resolves (running)
|
|
165
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
166
|
+
// Advance past the polling interval
|
|
167
|
+
jest.advanceTimersByTime(5000);
|
|
168
|
+
// Second poll resolves (success) + result fetch
|
|
169
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const { result } = hookResult;
|
|
173
|
+
// Task name should be from the first poll, not the second
|
|
174
|
+
expect(result.current.taskName).toBe('my::task');
|
|
175
|
+
expect(result.current.targets).toEqual(['host1']);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/* eslint-disable no-await-in-loop */
|
|
2
|
-
import { useState, useEffect } from 'react';
|
|
3
|
-
import { translate as __ } from 'foremanReact/common/I18n';
|
|
2
|
+
import { useState, useEffect, useRef } from 'react';
|
|
3
|
+
import { sprintf, translate as __ } from 'foremanReact/common/I18n';
|
|
4
4
|
import { API } from 'foremanReact/redux/API';
|
|
5
|
+
import { extractErrorMessage } from '../../common/helpers';
|
|
5
6
|
import {
|
|
6
7
|
STATUS,
|
|
7
8
|
COMPLETED_STATUSES,
|
|
@@ -9,13 +10,7 @@ import {
|
|
|
9
10
|
ROUTES,
|
|
10
11
|
} from '../../common/constants';
|
|
11
12
|
|
|
12
|
-
|
|
13
|
-
* Custom hook for polling job status
|
|
14
|
-
* @param {string} proxyId - Smart Proxy ID
|
|
15
|
-
* @param {string} jobId - Job ID to poll
|
|
16
|
-
* @returns {Object}
|
|
17
|
-
*/
|
|
18
|
-
const useJobPolling = (proxyId, jobId) => {
|
|
13
|
+
const useJobPolling = jobId => {
|
|
19
14
|
const [status, setStatus] = useState(STATUS.PENDING);
|
|
20
15
|
const [result, setResult] = useState(null);
|
|
21
16
|
const [error, setError] = useState(null);
|
|
@@ -26,13 +21,15 @@ const useJobPolling = (proxyId, jobId) => {
|
|
|
26
21
|
const [taskDescription, setTaskDescription] = useState(null);
|
|
27
22
|
const [taskParameters, setTaskParameters] = useState({});
|
|
28
23
|
const [targets, setTargets] = useState([]);
|
|
24
|
+
const [smartProxy, setSmartProxy] = useState(null);
|
|
25
|
+
const metadataLoaded = useRef(false);
|
|
29
26
|
|
|
30
27
|
// There are a bunch of checks of 'cancelled' here so that if the
|
|
31
28
|
// user navigates away while polling, we don't keep trying to update state.
|
|
32
29
|
useEffect(() => {
|
|
33
30
|
// Have to return undefined since we are returning a cleanup function
|
|
34
31
|
// otherwise and React wants all code paths to return something.
|
|
35
|
-
if (!
|
|
32
|
+
if (!jobId) return undefined;
|
|
36
33
|
|
|
37
34
|
let cancelled = false;
|
|
38
35
|
|
|
@@ -41,41 +38,40 @@ const useJobPolling = (proxyId, jobId) => {
|
|
|
41
38
|
|
|
42
39
|
while (!cancelled) {
|
|
43
40
|
try {
|
|
44
|
-
const { data: statusData
|
|
45
|
-
`${ROUTES.API.JOB_STATUS}?
|
|
41
|
+
const { data: statusData } = await API.get(
|
|
42
|
+
`${ROUTES.API.JOB_STATUS}?job_id=${jobId}`
|
|
46
43
|
);
|
|
47
44
|
|
|
48
45
|
if (cancelled) break;
|
|
49
46
|
|
|
50
|
-
if (statusCode !== 200) {
|
|
51
|
-
const errorMsg = statusData
|
|
52
|
-
? statusData.error || JSON.stringify(statusData)
|
|
53
|
-
: 'Unknown error';
|
|
54
|
-
throw new Error(`HTTP ${statusCode} - ${errorMsg}`);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
47
|
const jobStatus = statusData?.status;
|
|
58
48
|
if (!jobStatus) {
|
|
59
|
-
throw new Error('No job status returned');
|
|
49
|
+
throw new Error(__('No job status returned'));
|
|
60
50
|
}
|
|
61
51
|
|
|
62
52
|
setStatus(jobStatus);
|
|
63
|
-
setSubmittedAt(statusData.submitted_at || null);
|
|
64
53
|
setCompletedAt(statusData.completed_at || null);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
54
|
+
|
|
55
|
+
// Task metadata only needs to be set once since it never changes
|
|
56
|
+
if (!metadataLoaded.current) {
|
|
57
|
+
setSubmittedAt(statusData.submitted_at || null);
|
|
58
|
+
setTaskName(statusData.task_name || null);
|
|
59
|
+
setTaskDescription(statusData.task_description || null);
|
|
60
|
+
setTaskParameters(statusData.task_parameters || {});
|
|
61
|
+
setTargets(statusData.targets || []);
|
|
62
|
+
setSmartProxy(statusData.smart_proxy || null);
|
|
63
|
+
metadataLoaded.current = true;
|
|
64
|
+
}
|
|
69
65
|
|
|
70
66
|
// If job is complete, fetch results and break
|
|
71
67
|
if (COMPLETED_STATUSES.includes(jobStatus)) {
|
|
72
68
|
if (jobStatus === STATUS.INVALID) break;
|
|
73
69
|
try {
|
|
74
|
-
const { data: resultData
|
|
75
|
-
`${ROUTES.API.JOB_RESULT}?
|
|
70
|
+
const { data: resultData } = await API.get(
|
|
71
|
+
`${ROUTES.API.JOB_RESULT}?job_id=${jobId}`
|
|
76
72
|
);
|
|
77
73
|
|
|
78
|
-
if (!cancelled &&
|
|
74
|
+
if (!cancelled && resultData) {
|
|
79
75
|
setResult({
|
|
80
76
|
command: resultData.command || '',
|
|
81
77
|
result: resultData.value,
|
|
@@ -86,8 +82,10 @@ const useJobPolling = (proxyId, jobId) => {
|
|
|
86
82
|
// Don't fail the whole thing if result fetch fails
|
|
87
83
|
if (!cancelled) {
|
|
88
84
|
setError(
|
|
89
|
-
|
|
90
|
-
(
|
|
85
|
+
sprintf(
|
|
86
|
+
__('Failed to fetch job result: %s'),
|
|
87
|
+
extractErrorMessage(resultError)
|
|
88
|
+
)
|
|
91
89
|
);
|
|
92
90
|
setResult({ result: null, log: '' });
|
|
93
91
|
}
|
|
@@ -104,8 +102,10 @@ const useJobPolling = (proxyId, jobId) => {
|
|
|
104
102
|
} catch (err) {
|
|
105
103
|
if (!cancelled) {
|
|
106
104
|
setError(
|
|
107
|
-
|
|
108
|
-
(
|
|
105
|
+
sprintf(
|
|
106
|
+
__('Failed to fetch job status: %s'),
|
|
107
|
+
extractErrorMessage(err)
|
|
108
|
+
)
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
111
|
break;
|
|
@@ -123,7 +123,7 @@ const useJobPolling = (proxyId, jobId) => {
|
|
|
123
123
|
cancelled = true;
|
|
124
124
|
setIsPolling(false);
|
|
125
125
|
};
|
|
126
|
-
}, [
|
|
126
|
+
}, [jobId]);
|
|
127
127
|
|
|
128
128
|
return {
|
|
129
129
|
status,
|
|
@@ -136,6 +136,7 @@ const useJobPolling = (proxyId, jobId) => {
|
|
|
136
136
|
taskDescription,
|
|
137
137
|
taskParameters,
|
|
138
138
|
targets,
|
|
139
|
+
smartProxy,
|
|
139
140
|
};
|
|
140
141
|
};
|
|
141
142
|
|
|
@@ -16,9 +16,7 @@ const TaskExecution = () => {
|
|
|
16
16
|
const showMessage = useShowMessage();
|
|
17
17
|
|
|
18
18
|
const params = new URLSearchParams(location.search);
|
|
19
|
-
const proxyId = params.get('proxy_id');
|
|
20
19
|
const jobId = params.get('job_id');
|
|
21
|
-
const proxyName = params.get('proxy_name');
|
|
22
20
|
|
|
23
21
|
const {
|
|
24
22
|
status: jobStatus,
|
|
@@ -31,7 +29,8 @@ const TaskExecution = () => {
|
|
|
31
29
|
taskDescription,
|
|
32
30
|
taskParameters,
|
|
33
31
|
targets,
|
|
34
|
-
|
|
32
|
+
smartProxy,
|
|
33
|
+
} = useJobPolling(jobId);
|
|
35
34
|
|
|
36
35
|
useEffect(() => {
|
|
37
36
|
if (pollError) {
|
|
@@ -41,16 +40,14 @@ const TaskExecution = () => {
|
|
|
41
40
|
|
|
42
41
|
// Redirect if missing required params
|
|
43
42
|
useEffect(() => {
|
|
44
|
-
if (!
|
|
45
|
-
showMessage(
|
|
46
|
-
__('Invalid task execution URL - missing required parameters')
|
|
47
|
-
);
|
|
43
|
+
if (!jobId) {
|
|
44
|
+
showMessage(__('Invalid task execution URL - missing job ID'));
|
|
48
45
|
history.push(ROUTES.PAGES.LAUNCH_TASK);
|
|
49
46
|
}
|
|
50
|
-
}, [
|
|
47
|
+
}, [jobId, showMessage, history]);
|
|
51
48
|
|
|
52
49
|
// Don't render if missing required params
|
|
53
|
-
if (!
|
|
50
|
+
if (!jobId) {
|
|
54
51
|
return null;
|
|
55
52
|
}
|
|
56
53
|
|
|
@@ -66,7 +63,9 @@ const TaskExecution = () => {
|
|
|
66
63
|
const isComplete = COMPLETED_STATUSES.includes(jobStatus);
|
|
67
64
|
const jobCommand = jobData?.command;
|
|
68
65
|
const jobResult = jobData?.result;
|
|
69
|
-
const jobLog =
|
|
66
|
+
const jobLog = jobData
|
|
67
|
+
? `OpenBolt command: ${jobCommand}\n${stripAnsi(jobData.log)}`
|
|
68
|
+
: '';
|
|
70
69
|
|
|
71
70
|
return (
|
|
72
71
|
<Stack hasGutter>
|
|
@@ -82,8 +81,7 @@ const TaskExecution = () => {
|
|
|
82
81
|
|
|
83
82
|
<StackItem>
|
|
84
83
|
<ExecutionDisplay
|
|
85
|
-
|
|
86
|
-
proxyName={proxyName}
|
|
84
|
+
smartProxy={smartProxy}
|
|
87
85
|
jobId={jobId}
|
|
88
86
|
jobStatus={jobStatus}
|
|
89
87
|
isPolling={isPolling}
|
|
@@ -1,23 +1,14 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import PropTypes from 'prop-types';
|
|
3
|
-
import { translate as __ } from 'foremanReact/common/I18n';
|
|
3
|
+
import { sprintf, translate as __ } from 'foremanReact/common/I18n';
|
|
4
4
|
import { Popover, Button } from '@patternfly/react-core';
|
|
5
5
|
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
6
|
+
import { displayValue } from '../common/helpers';
|
|
6
7
|
|
|
7
8
|
const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
|
|
8
9
|
const hasParameters =
|
|
9
10
|
taskParameters && Object.keys(taskParameters).length > 0;
|
|
10
11
|
|
|
11
|
-
const displayValue = value => {
|
|
12
|
-
if (value === null || value === undefined) {
|
|
13
|
-
return '-';
|
|
14
|
-
}
|
|
15
|
-
if (typeof value === 'object') {
|
|
16
|
-
return JSON.stringify(value);
|
|
17
|
-
}
|
|
18
|
-
return String(value);
|
|
19
|
-
};
|
|
20
|
-
|
|
21
12
|
const popoverContent = (
|
|
22
13
|
<div style={{ maxWidth: '500px' }}>
|
|
23
14
|
{taskDescription && (
|
|
@@ -41,6 +32,7 @@ const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
|
|
|
41
32
|
borders
|
|
42
33
|
isStriped
|
|
43
34
|
isStickyHeader
|
|
35
|
+
aria-label={__('Task parameters')}
|
|
44
36
|
style={{
|
|
45
37
|
border: '1px solid var(--pf-v5-global--BorderColor--100)',
|
|
46
38
|
}}
|
|
@@ -74,7 +66,12 @@ const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
|
|
|
74
66
|
|
|
75
67
|
return (
|
|
76
68
|
<Popover bodyContent={popoverContent} position="right">
|
|
77
|
-
<Button
|
|
69
|
+
<Button
|
|
70
|
+
variant="link"
|
|
71
|
+
isInline
|
|
72
|
+
className="pf-v5-u-font-family-monospace"
|
|
73
|
+
aria-label={sprintf(__('View details for task %s'), taskName)}
|
|
74
|
+
>
|
|
78
75
|
{taskName}
|
|
79
76
|
</Button>
|
|
80
77
|
</Popover>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { Provider } from 'react-redux';
|
|
4
|
+
import { createStore } from 'redux';
|
|
5
|
+
import { API } from 'foremanReact/redux/API';
|
|
6
|
+
import { addToast } from 'foremanReact/components/ToastsList';
|
|
7
|
+
import TaskHistory from '../index';
|
|
8
|
+
|
|
9
|
+
const mockStore = createStore(() => ({}));
|
|
10
|
+
const wrapper = ({ children }) => (
|
|
11
|
+
<Provider store={mockStore}>{children}</Provider>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
jest.clearAllMocks();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const sampleJobs = [
|
|
19
|
+
{
|
|
20
|
+
job_id: 'job-1',
|
|
21
|
+
task_name: 'mymod::install',
|
|
22
|
+
task_description: 'Install a package',
|
|
23
|
+
task_parameters: { name: 'nginx' },
|
|
24
|
+
status: 'success',
|
|
25
|
+
targets: ['host1.example.com'],
|
|
26
|
+
submitted_at: '2026-01-15T10:00:00Z',
|
|
27
|
+
completed_at: '2026-01-15T10:01:00Z',
|
|
28
|
+
duration: 60,
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
job_id: 'job-2',
|
|
32
|
+
task_name: 'mymod::mytask',
|
|
33
|
+
task_description: 'Restart a service',
|
|
34
|
+
task_parameters: {},
|
|
35
|
+
status: 'running',
|
|
36
|
+
targets: ['host2.example.com'],
|
|
37
|
+
submitted_at: '2026-01-15T11:00:00Z',
|
|
38
|
+
completed_at: null,
|
|
39
|
+
duration: null,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
describe('TaskHistory', () => {
|
|
44
|
+
test('shows loading spinner initially', () => {
|
|
45
|
+
API.get.mockReturnValue(new Promise(() => {}));
|
|
46
|
+
render(<TaskHistory />, { wrapper });
|
|
47
|
+
expect(screen.getByLabelText('Loading task history')).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('renders task history table with jobs', async () => {
|
|
51
|
+
API.get.mockResolvedValue({
|
|
52
|
+
data: { results: sampleJobs, total: 2 },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
render(<TaskHistory />, { wrapper });
|
|
56
|
+
|
|
57
|
+
await waitFor(() => {
|
|
58
|
+
expect(screen.getByText('mymod::install')).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
expect(screen.getByText('mymod::mytask')).toBeInTheDocument();
|
|
61
|
+
expect(screen.getByText('success')).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText('running')).toBeInTheDocument();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('shows empty state when no jobs exist', async () => {
|
|
66
|
+
API.get.mockResolvedValue({
|
|
67
|
+
data: { results: [], total: 0 },
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
render(<TaskHistory />, { wrapper });
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(screen.getByText('No task history found')).toBeInTheDocument();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('shows toast and stops loading when API call fails', async () => {
|
|
78
|
+
API.get.mockRejectedValue({ message: 'Network error' });
|
|
79
|
+
|
|
80
|
+
render(<TaskHistory />, { wrapper });
|
|
81
|
+
|
|
82
|
+
await waitFor(() => {
|
|
83
|
+
expect(
|
|
84
|
+
screen.queryByLabelText('Loading task history')
|
|
85
|
+
).not.toBeInTheDocument();
|
|
86
|
+
});
|
|
87
|
+
expect(addToast).toHaveBeenCalledWith(
|
|
88
|
+
expect.objectContaining({
|
|
89
|
+
type: 'danger',
|
|
90
|
+
message: expect.stringContaining('Failed to load task history'),
|
|
91
|
+
})
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('calls API with page and per_page params', async () => {
|
|
96
|
+
API.get.mockResolvedValue({
|
|
97
|
+
data: { results: [], total: 0 },
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
render(<TaskHistory />, { wrapper });
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(API.get).toHaveBeenCalledWith(expect.stringContaining('page=1'));
|
|
104
|
+
expect(API.get).toHaveBeenCalledWith(
|
|
105
|
+
expect.stringContaining('per_page=20')
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import TaskPopover from '../TaskPopover';
|
|
4
|
+
|
|
5
|
+
describe('TaskPopover', () => {
|
|
6
|
+
test('renders task name as button', () => {
|
|
7
|
+
render(<TaskPopover taskName="mymod::install" />);
|
|
8
|
+
expect(screen.getByText('mymod::install')).toBeInTheDocument();
|
|
9
|
+
expect(
|
|
10
|
+
screen.getByRole('button', { name: /mymod::install/ })
|
|
11
|
+
).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('renders task name button with description and parameters provided', () => {
|
|
15
|
+
render(
|
|
16
|
+
<TaskPopover
|
|
17
|
+
taskName="test::task"
|
|
18
|
+
taskDescription="A test task"
|
|
19
|
+
taskParameters={{ name: 'nginx' }}
|
|
20
|
+
/>
|
|
21
|
+
);
|
|
22
|
+
expect(
|
|
23
|
+
screen.getByRole('button', { name: /test::task/ })
|
|
24
|
+
).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { translate as __ } from 'foremanReact/common/I18n';
|
|
2
|
+
import { sprintf, translate as __ } from 'foremanReact/common/I18n';
|
|
3
3
|
import { API } from 'foremanReact/redux/API';
|
|
4
4
|
import {
|
|
5
5
|
Label,
|
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
UnknownIcon,
|
|
23
23
|
} from '@patternfly/react-icons';
|
|
24
24
|
import { ROUTES, STATUS } from '../common/constants';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
useShowMessage,
|
|
27
|
+
extractErrorMessage,
|
|
28
|
+
formatDuration,
|
|
29
|
+
formatDate,
|
|
30
|
+
} from '../common/helpers';
|
|
26
31
|
import HostsPopover from '../common/HostsPopover';
|
|
27
32
|
import TaskPopover from './TaskPopover';
|
|
28
33
|
|
|
@@ -44,21 +49,6 @@ const getStatusLabel = status => {
|
|
|
44
49
|
);
|
|
45
50
|
};
|
|
46
51
|
|
|
47
|
-
const formatDuration = duration => {
|
|
48
|
-
if (!duration) return '-';
|
|
49
|
-
const seconds = Math.round(duration);
|
|
50
|
-
if (seconds < 60) return `${seconds}s`;
|
|
51
|
-
const minutes = Math.floor(seconds / 60);
|
|
52
|
-
const remainingSeconds = seconds % 60;
|
|
53
|
-
return `${minutes}m ${remainingSeconds}s`;
|
|
54
|
-
};
|
|
55
|
-
|
|
56
|
-
const formatDate = dateString => {
|
|
57
|
-
if (!dateString) return '-';
|
|
58
|
-
const date = new Date(dateString);
|
|
59
|
-
return date.toLocaleString();
|
|
60
|
-
};
|
|
61
|
-
|
|
62
52
|
const TaskHistory = () => {
|
|
63
53
|
const [taskHistory, setTaskHistory] = useState([]);
|
|
64
54
|
const [isLoadingTaskHistory, setIsLoadingTaskHistory] = useState(true);
|
|
@@ -75,17 +65,23 @@ const TaskHistory = () => {
|
|
|
75
65
|
setIsLoadingTaskHistory(true);
|
|
76
66
|
|
|
77
67
|
try {
|
|
78
|
-
const { data
|
|
68
|
+
const { data } = await API.get(
|
|
79
69
|
`${ROUTES.API.TASK_HISTORY}?page=${page}&per_page=${perPage}`
|
|
80
70
|
);
|
|
81
71
|
|
|
82
|
-
if (!cancelled &&
|
|
72
|
+
if (!cancelled && data) {
|
|
83
73
|
setTaskHistory(data.results || []);
|
|
84
74
|
setTotal(data.total || 0);
|
|
85
75
|
}
|
|
86
76
|
} catch (error) {
|
|
87
|
-
if (!cancelled)
|
|
88
|
-
showMessage(
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
showMessage(
|
|
79
|
+
sprintf(
|
|
80
|
+
__('Failed to load task history: %s'),
|
|
81
|
+
extractErrorMessage(error)
|
|
82
|
+
)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
89
85
|
} finally {
|
|
90
86
|
if (!cancelled) setIsLoadingTaskHistory(false);
|
|
91
87
|
}
|
|
@@ -100,7 +96,7 @@ const TaskHistory = () => {
|
|
|
100
96
|
|
|
101
97
|
const spinner = () => (
|
|
102
98
|
<Bullseye>
|
|
103
|
-
<Spinner size="xl" />
|
|
99
|
+
<Spinner size="xl" aria-label={__('Loading task history')} />
|
|
104
100
|
</Bullseye>
|
|
105
101
|
);
|
|
106
102
|
|
|
@@ -120,7 +116,7 @@ const TaskHistory = () => {
|
|
|
120
116
|
const jobTable = () => (
|
|
121
117
|
<>
|
|
122
118
|
<Table
|
|
123
|
-
aria-label=
|
|
119
|
+
aria-label={__('Task history table')}
|
|
124
120
|
borders
|
|
125
121
|
isStriped
|
|
126
122
|
isStickyHeader
|
|
@@ -131,7 +127,7 @@ const TaskHistory = () => {
|
|
|
131
127
|
<Th modifier="wrap">{__('Task Name')}</Th>
|
|
132
128
|
<Th modifier="wrap">{__('Status')}</Th>
|
|
133
129
|
<Th modifier="wrap">{__('Targets')}</Th>
|
|
134
|
-
<Th modifier="wrap">{__('
|
|
130
|
+
<Th modifier="wrap">{__('Submitted')}</Th>
|
|
135
131
|
<Th modifier="wrap">{__('Completed')}</Th>
|
|
136
132
|
<Th modifier="wrap">{__('Duration')}</Th>
|
|
137
133
|
<Th modifier="wrap">{__('Details')}</Th>
|
|
@@ -158,11 +154,7 @@ const TaskHistory = () => {
|
|
|
158
154
|
<Td hasRightBorder>{formatDuration(job.duration)}</Td>
|
|
159
155
|
<Td hasRightBorder>
|
|
160
156
|
<a
|
|
161
|
-
href={`${ROUTES.PAGES.TASK_EXECUTION}?
|
|
162
|
-
job.smart_proxy.id
|
|
163
|
-
}&job_id=${job.job_id}&proxy_name=${encodeURIComponent(
|
|
164
|
-
job.smart_proxy.name
|
|
165
|
-
)}`}
|
|
157
|
+
href={`${ROUTES.PAGES.TASK_EXECUTION}?job_id=${job.job_id}`}
|
|
166
158
|
aria-label={__('View Details')}
|
|
167
159
|
title={__('View Details')}
|
|
168
160
|
>
|