foreman_openbolt 0.0.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 +7 -0
- data/LICENSE +619 -0
- data/README.md +46 -0
- data/Rakefile +106 -0
- data/app/controllers/foreman_openbolt/task_controller.rb +298 -0
- data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +40 -0
- data/app/lib/actions/foreman_openbolt/poll_task_status.rb +151 -0
- data/app/models/foreman_openbolt/task_job.rb +110 -0
- data/app/views/foreman_openbolt/react_page.html.erb +1 -0
- data/config/routes.rb +24 -0
- data/db/migrate/20250819000000_create_openbolt_task_jobs.rb +25 -0
- data/db/migrate/20250925000000_add_command_to_openbolt_task_jobs.rb +7 -0
- data/db/migrate/20251001000000_add_task_description_to_task_jobs.rb +7 -0
- data/db/seeds.d/001_add_openbolt_feature.rb +4 -0
- data/lib/foreman_openbolt/engine.rb +169 -0
- data/lib/foreman_openbolt/version.rb +5 -0
- data/lib/foreman_openbolt.rb +7 -0
- data/lib/proxy_api/openbolt.rb +53 -0
- data/lib/tasks/foreman_openbolt_tasks.rake +48 -0
- data/locale/Makefile +73 -0
- data/locale/en/foreman_openbolt.po +19 -0
- data/locale/foreman_openbolt.pot +19 -0
- data/locale/gemspec.rb +7 -0
- data/package.json +41 -0
- data/test/factories/foreman_openbolt_factories.rb +7 -0
- data/test/test_plugin_helper.rb +8 -0
- data/test/unit/foreman_openbolt_test.rb +13 -0
- data/webpack/global_index.js +4 -0
- data/webpack/global_test_setup.js +11 -0
- data/webpack/index.js +19 -0
- data/webpack/src/Components/LaunchTask/EmptyContent.js +24 -0
- data/webpack/src/Components/LaunchTask/FieldTable.js +147 -0
- data/webpack/src/Components/LaunchTask/HostSelector/HostSearch.js +29 -0
- data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +208 -0
- data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +113 -0
- data/webpack/src/Components/LaunchTask/HostSelector/hostgroups.gql +9 -0
- data/webpack/src/Components/LaunchTask/HostSelector/hosts.gql +10 -0
- data/webpack/src/Components/LaunchTask/HostSelector/index.js +261 -0
- data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +116 -0
- data/webpack/src/Components/LaunchTask/ParameterField.js +145 -0
- data/webpack/src/Components/LaunchTask/ParametersSection.js +66 -0
- data/webpack/src/Components/LaunchTask/SmartProxySelect.js +51 -0
- data/webpack/src/Components/LaunchTask/TaskSelect.js +84 -0
- data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +63 -0
- data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +48 -0
- data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +64 -0
- data/webpack/src/Components/LaunchTask/index.js +333 -0
- data/webpack/src/Components/TaskExecution/ExecutionDetails.js +188 -0
- data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +99 -0
- data/webpack/src/Components/TaskExecution/LoadingIndicator.js +51 -0
- data/webpack/src/Components/TaskExecution/ResultDisplay.js +174 -0
- data/webpack/src/Components/TaskExecution/TaskDetails.js +99 -0
- data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +142 -0
- data/webpack/src/Components/TaskExecution/index.js +130 -0
- data/webpack/src/Components/TaskHistory/TaskPopover.js +95 -0
- data/webpack/src/Components/TaskHistory/index.js +199 -0
- data/webpack/src/Components/common/HostsPopover.js +49 -0
- data/webpack/src/Components/common/constants.js +44 -0
- data/webpack/src/Components/common/helpers.js +19 -0
- data/webpack/src/Pages/LaunchTaskPage.js +12 -0
- data/webpack/src/Pages/TaskExecutionPage.js +12 -0
- data/webpack/src/Pages/TaskHistoryPage.js +12 -0
- data/webpack/src/Router/routes.js +30 -0
- data/webpack/test_setup.js +17 -0
- data/webpack/webpack.config.js +7 -0
- metadata +208 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
4
|
+
import { API } from 'foremanReact/redux/API';
|
|
5
|
+
import {
|
|
6
|
+
STATUS,
|
|
7
|
+
COMPLETED_STATUSES,
|
|
8
|
+
POLLING_CONFIG,
|
|
9
|
+
ROUTES,
|
|
10
|
+
} from '../../common/constants';
|
|
11
|
+
|
|
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) => {
|
|
19
|
+
const [status, setStatus] = useState(STATUS.PENDING);
|
|
20
|
+
const [result, setResult] = useState(null);
|
|
21
|
+
const [error, setError] = useState(null);
|
|
22
|
+
const [isPolling, setIsPolling] = useState(false);
|
|
23
|
+
const [submittedAt, setSubmittedAt] = useState(null);
|
|
24
|
+
const [completedAt, setCompletedAt] = useState(null);
|
|
25
|
+
const [taskName, setTaskName] = useState(null);
|
|
26
|
+
const [taskDescription, setTaskDescription] = useState(null);
|
|
27
|
+
const [taskParameters, setTaskParameters] = useState({});
|
|
28
|
+
const [targets, setTargets] = useState([]);
|
|
29
|
+
|
|
30
|
+
// There are a bunch of checks of 'cancelled' here so that if the
|
|
31
|
+
// user navigates away while polling, we don't keep trying to update state.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// Have to return undefined since we are returning a cleanup function
|
|
34
|
+
// otherwise and React wants all code paths to return something.
|
|
35
|
+
if (!proxyId || !jobId) return undefined;
|
|
36
|
+
|
|
37
|
+
let cancelled = false;
|
|
38
|
+
|
|
39
|
+
const poll = async () => {
|
|
40
|
+
setIsPolling(true);
|
|
41
|
+
|
|
42
|
+
while (!cancelled) {
|
|
43
|
+
try {
|
|
44
|
+
const { data: statusData, status: statusCode } = await API.get(
|
|
45
|
+
`${ROUTES.API.JOB_STATUS}?proxy_id=${proxyId}&job_id=${jobId}`
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
if (cancelled) break;
|
|
49
|
+
|
|
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
|
+
const jobStatus = statusData?.status;
|
|
58
|
+
if (!jobStatus) {
|
|
59
|
+
throw new Error('No job status returned');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setStatus(jobStatus);
|
|
63
|
+
setSubmittedAt(statusData.submitted_at || null);
|
|
64
|
+
setCompletedAt(statusData.completed_at || null);
|
|
65
|
+
setTaskName(statusData.task_name || null);
|
|
66
|
+
setTaskDescription(statusData.task_description || null);
|
|
67
|
+
setTaskParameters(statusData.task_parameters || {});
|
|
68
|
+
setTargets(statusData.targets || []);
|
|
69
|
+
|
|
70
|
+
// If job is complete, fetch results and break
|
|
71
|
+
if (COMPLETED_STATUSES.includes(jobStatus)) {
|
|
72
|
+
if (jobStatus === STATUS.INVALID) break;
|
|
73
|
+
try {
|
|
74
|
+
const { data: resultData, status: resultCode } = await API.get(
|
|
75
|
+
`${ROUTES.API.JOB_RESULT}?proxy_id=${proxyId}&job_id=${jobId}`
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
if (!cancelled && resultCode === 200 && resultData) {
|
|
79
|
+
setResult({
|
|
80
|
+
command: resultData.command || '',
|
|
81
|
+
result: resultData.value,
|
|
82
|
+
log: resultData.log || '',
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
} catch (resultError) {
|
|
86
|
+
// Don't fail the whole thing if result fetch fails
|
|
87
|
+
if (!cancelled) {
|
|
88
|
+
setError(
|
|
89
|
+
__('Failed to fetch job result: ') +
|
|
90
|
+
(resultError.message || 'Unknown error')
|
|
91
|
+
);
|
|
92
|
+
setResult({ result: null, log: '' });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Wait before next poll
|
|
99
|
+
if (!cancelled) {
|
|
100
|
+
await new Promise(resolve =>
|
|
101
|
+
setTimeout(resolve, POLLING_CONFIG.INTERVAL)
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (!cancelled) {
|
|
106
|
+
setError(
|
|
107
|
+
__('Failed to fetch job status: ') +
|
|
108
|
+
(err.message || 'Unknown error')
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!cancelled) {
|
|
116
|
+
setIsPolling(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
poll();
|
|
121
|
+
|
|
122
|
+
return () => {
|
|
123
|
+
cancelled = true;
|
|
124
|
+
setIsPolling(false);
|
|
125
|
+
};
|
|
126
|
+
}, [proxyId, jobId]);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
status,
|
|
130
|
+
result,
|
|
131
|
+
error,
|
|
132
|
+
isPolling,
|
|
133
|
+
submittedAt,
|
|
134
|
+
completedAt,
|
|
135
|
+
taskName,
|
|
136
|
+
taskDescription,
|
|
137
|
+
taskParameters,
|
|
138
|
+
targets,
|
|
139
|
+
};
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export default useJobPolling;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { useLocation, useHistory } from 'react-router-dom';
|
|
3
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
4
|
+
import { Alert, Button, Stack, StackItem } from '@patternfly/react-core';
|
|
5
|
+
|
|
6
|
+
import ExecutionDisplay from './ExecutionDisplay';
|
|
7
|
+
import LoadingIndicator from './LoadingIndicator';
|
|
8
|
+
import ResultDisplay from './ResultDisplay';
|
|
9
|
+
import useJobPolling from './hooks/useJobPolling';
|
|
10
|
+
import { COMPLETED_STATUSES, ROUTES } from '../common/constants';
|
|
11
|
+
import { useShowMessage } from '../common/helpers';
|
|
12
|
+
|
|
13
|
+
const TaskExecution = () => {
|
|
14
|
+
const location = useLocation();
|
|
15
|
+
const history = useHistory();
|
|
16
|
+
const showMessage = useShowMessage();
|
|
17
|
+
|
|
18
|
+
const params = new URLSearchParams(location.search);
|
|
19
|
+
const proxyId = params.get('proxy_id');
|
|
20
|
+
const jobId = params.get('job_id');
|
|
21
|
+
const proxyName = params.get('proxy_name');
|
|
22
|
+
|
|
23
|
+
const {
|
|
24
|
+
status: jobStatus,
|
|
25
|
+
result: jobData,
|
|
26
|
+
error: pollError,
|
|
27
|
+
isPolling,
|
|
28
|
+
submittedAt,
|
|
29
|
+
completedAt,
|
|
30
|
+
taskName,
|
|
31
|
+
taskDescription,
|
|
32
|
+
taskParameters,
|
|
33
|
+
targets,
|
|
34
|
+
} = useJobPolling(proxyId, jobId);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (pollError) {
|
|
38
|
+
showMessage(pollError);
|
|
39
|
+
}
|
|
40
|
+
}, [pollError, showMessage]);
|
|
41
|
+
|
|
42
|
+
// Redirect if missing required params
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!proxyId || !jobId) {
|
|
45
|
+
showMessage(
|
|
46
|
+
__('Invalid task execution URL - missing required parameters')
|
|
47
|
+
);
|
|
48
|
+
history.push(ROUTES.PAGES.LAUNCH_TASK);
|
|
49
|
+
}
|
|
50
|
+
}, [proxyId, jobId, showMessage, history]);
|
|
51
|
+
|
|
52
|
+
// Don't render if missing required params
|
|
53
|
+
if (!proxyId || !jobId) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const stripAnsi = str => {
|
|
58
|
+
if (!str || typeof str !== 'string') return str;
|
|
59
|
+
return str.replace(
|
|
60
|
+
/* eslint-disable no-control-regex */
|
|
61
|
+
/[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g,
|
|
62
|
+
''
|
|
63
|
+
);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const isComplete = COMPLETED_STATUSES.includes(jobStatus);
|
|
67
|
+
const jobCommand = jobData?.command;
|
|
68
|
+
const jobResult = jobData?.result;
|
|
69
|
+
const jobLog = `OpenBolt command: ${jobCommand}\n${stripAnsi(jobData?.log)}`;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Stack hasGutter>
|
|
73
|
+
<StackItem>
|
|
74
|
+
<Button
|
|
75
|
+
variant="secondary"
|
|
76
|
+
onClick={() => history.push(ROUTES.PAGES.LAUNCH_TASK)}
|
|
77
|
+
className="pf-v5-u-mb-md"
|
|
78
|
+
>
|
|
79
|
+
{__('Run Another Task')}
|
|
80
|
+
</Button>
|
|
81
|
+
</StackItem>
|
|
82
|
+
|
|
83
|
+
<StackItem>
|
|
84
|
+
<ExecutionDisplay
|
|
85
|
+
proxyId={proxyId}
|
|
86
|
+
proxyName={proxyName}
|
|
87
|
+
jobId={jobId}
|
|
88
|
+
jobStatus={jobStatus}
|
|
89
|
+
isPolling={isPolling}
|
|
90
|
+
targets={targets}
|
|
91
|
+
submittedAt={submittedAt}
|
|
92
|
+
completedAt={completedAt}
|
|
93
|
+
taskName={taskName}
|
|
94
|
+
taskDescription={taskDescription}
|
|
95
|
+
taskParameters={taskParameters}
|
|
96
|
+
/>
|
|
97
|
+
</StackItem>
|
|
98
|
+
|
|
99
|
+
{isPolling && (
|
|
100
|
+
<StackItem>
|
|
101
|
+
<LoadingIndicator jobStatus={jobStatus} />
|
|
102
|
+
</StackItem>
|
|
103
|
+
)}
|
|
104
|
+
|
|
105
|
+
{!isPolling && jobResult && (
|
|
106
|
+
<StackItem>
|
|
107
|
+
<ResultDisplay jobResult={jobResult} jobLog={jobLog} />
|
|
108
|
+
</StackItem>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
{!isPolling && !jobResult && isComplete && (
|
|
112
|
+
<StackItem>
|
|
113
|
+
<Alert variant="warning" title={__('No Results')} isInline>
|
|
114
|
+
{__('No results for this task run could be retrieved.')}
|
|
115
|
+
</Alert>
|
|
116
|
+
</StackItem>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
{pollError && (
|
|
120
|
+
<StackItem>
|
|
121
|
+
<Alert variant="danger" title={__('Error')} isInline>
|
|
122
|
+
{pollError}
|
|
123
|
+
</Alert>
|
|
124
|
+
</StackItem>
|
|
125
|
+
)}
|
|
126
|
+
</Stack>
|
|
127
|
+
);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
export default TaskExecution;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
4
|
+
import { Popover, Button } from '@patternfly/react-core';
|
|
5
|
+
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
6
|
+
|
|
7
|
+
const TaskPopover = ({ taskName, taskDescription, taskParameters }) => {
|
|
8
|
+
const hasParameters =
|
|
9
|
+
taskParameters && Object.keys(taskParameters).length > 0;
|
|
10
|
+
|
|
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
|
+
const popoverContent = (
|
|
22
|
+
<div style={{ maxWidth: '500px' }}>
|
|
23
|
+
{taskDescription && (
|
|
24
|
+
<div style={{ marginBottom: '1rem' }}>
|
|
25
|
+
<strong>{__('Description:')}</strong>
|
|
26
|
+
<p>{taskDescription}</p>
|
|
27
|
+
</div>
|
|
28
|
+
)}
|
|
29
|
+
|
|
30
|
+
{hasParameters && (
|
|
31
|
+
<div>
|
|
32
|
+
<strong>{__('Parameters:')}</strong>
|
|
33
|
+
<div
|
|
34
|
+
style={{
|
|
35
|
+
maxHeight: '300px',
|
|
36
|
+
overflowY: 'auto',
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<Table
|
|
40
|
+
variant="compact"
|
|
41
|
+
borders
|
|
42
|
+
isStriped
|
|
43
|
+
isStickyHeader
|
|
44
|
+
style={{
|
|
45
|
+
border: '1px solid var(--pf-v5-global--BorderColor--100)',
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<Thead>
|
|
49
|
+
<Tr>
|
|
50
|
+
<Th width={30}>{__('Name')}</Th>
|
|
51
|
+
<Th width={70}>{__('Value')}</Th>
|
|
52
|
+
</Tr>
|
|
53
|
+
</Thead>
|
|
54
|
+
<Tbody>
|
|
55
|
+
{Object.entries(taskParameters).map(([key, value]) => (
|
|
56
|
+
<Tr key={key}>
|
|
57
|
+
<Td className="pf-v5-u-font-family-monospace">{key}</Td>
|
|
58
|
+
<Td className="pf-v5-u-font-family-monospace">
|
|
59
|
+
{displayValue(value)}
|
|
60
|
+
</Td>
|
|
61
|
+
</Tr>
|
|
62
|
+
))}
|
|
63
|
+
</Tbody>
|
|
64
|
+
</Table>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{!taskDescription && !hasParameters && (
|
|
70
|
+
<div>{__('No additional details available')}</div>
|
|
71
|
+
)}
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Popover bodyContent={popoverContent} position="right">
|
|
77
|
+
<Button variant="link" isInline className="pf-v5-u-font-family-monospace">
|
|
78
|
+
{taskName}
|
|
79
|
+
</Button>
|
|
80
|
+
</Popover>
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
TaskPopover.propTypes = {
|
|
85
|
+
taskName: PropTypes.string.isRequired,
|
|
86
|
+
taskDescription: PropTypes.string,
|
|
87
|
+
taskParameters: PropTypes.object,
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
TaskPopover.defaultProps = {
|
|
91
|
+
taskDescription: null,
|
|
92
|
+
taskParameters: {},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export default TaskPopover;
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
3
|
+
import { API } from 'foremanReact/redux/API';
|
|
4
|
+
import {
|
|
5
|
+
Label,
|
|
6
|
+
Pagination,
|
|
7
|
+
EmptyState,
|
|
8
|
+
EmptyStateIcon,
|
|
9
|
+
EmptyStateHeader,
|
|
10
|
+
EmptyStateBody,
|
|
11
|
+
Spinner,
|
|
12
|
+
Bullseye,
|
|
13
|
+
} from '@patternfly/react-core';
|
|
14
|
+
import { Table, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table';
|
|
15
|
+
import {
|
|
16
|
+
ArrowRightIcon,
|
|
17
|
+
CheckCircleIcon,
|
|
18
|
+
ExclamationCircleIcon,
|
|
19
|
+
InfoCircleIcon,
|
|
20
|
+
InProgressIcon,
|
|
21
|
+
OutlinedClockIcon,
|
|
22
|
+
UnknownIcon,
|
|
23
|
+
} from '@patternfly/react-icons';
|
|
24
|
+
import { ROUTES, STATUS } from '../common/constants';
|
|
25
|
+
import { useShowMessage } from '../common/helpers';
|
|
26
|
+
import HostsPopover from '../common/HostsPopover';
|
|
27
|
+
import TaskPopover from './TaskPopover';
|
|
28
|
+
|
|
29
|
+
const getStatusLabel = status => {
|
|
30
|
+
const configs = {
|
|
31
|
+
[STATUS.SUCCESS]: { color: 'green', icon: <CheckCircleIcon /> },
|
|
32
|
+
[STATUS.FAILURE]: { color: 'red', icon: <ExclamationCircleIcon /> },
|
|
33
|
+
[STATUS.EXCEPTION]: { color: 'orange', icon: <ExclamationCircleIcon /> },
|
|
34
|
+
[STATUS.INVALID]: { color: 'yellow', icon: <ExclamationCircleIcon /> },
|
|
35
|
+
[STATUS.RUNNING]: { color: 'blue', icon: <InProgressIcon /> },
|
|
36
|
+
[STATUS.PENDING]: { color: 'blue', icon: <OutlinedClockIcon /> },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const config = configs[status] || { color: 'grey', icon: <UnknownIcon /> };
|
|
40
|
+
return (
|
|
41
|
+
<Label color={config.color} icon={config.icon}>
|
|
42
|
+
{status}
|
|
43
|
+
</Label>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
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
|
+
const TaskHistory = () => {
|
|
63
|
+
const [taskHistory, setTaskHistory] = useState([]);
|
|
64
|
+
const [isLoadingTaskHistory, setIsLoadingTaskHistory] = useState(true);
|
|
65
|
+
const [page, setPage] = useState(1);
|
|
66
|
+
const [perPage, setPerPage] = useState(20);
|
|
67
|
+
const [total, setTotal] = useState(0);
|
|
68
|
+
const showMessage = useShowMessage();
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
|
|
73
|
+
const fetchTaskHistory = async () => {
|
|
74
|
+
if (cancelled) return;
|
|
75
|
+
setIsLoadingTaskHistory(true);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { data, status } = await API.get(
|
|
79
|
+
`${ROUTES.API.TASK_HISTORY}?page=${page}&per_page=${perPage}`
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!cancelled && status === 200 && data) {
|
|
83
|
+
setTaskHistory(data.results || []);
|
|
84
|
+
setTotal(data.total || 0);
|
|
85
|
+
}
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (!cancelled)
|
|
88
|
+
showMessage(__('Failed to load task history: ') + error.message);
|
|
89
|
+
} finally {
|
|
90
|
+
if (!cancelled) setIsLoadingTaskHistory(false);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
fetchTaskHistory();
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, [page, perPage, showMessage]);
|
|
100
|
+
|
|
101
|
+
const spinner = () => (
|
|
102
|
+
<Bullseye>
|
|
103
|
+
<Spinner size="xl" />
|
|
104
|
+
</Bullseye>
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const noJobs = () => (
|
|
108
|
+
<EmptyState>
|
|
109
|
+
<EmptyStateHeader
|
|
110
|
+
titleText={__('No task history found')}
|
|
111
|
+
icon={<EmptyStateIcon icon={InfoCircleIcon} />}
|
|
112
|
+
headingLevel="h2"
|
|
113
|
+
/>
|
|
114
|
+
<EmptyStateBody>
|
|
115
|
+
{__('Run an OpenBolt task to see it appear here.')}
|
|
116
|
+
</EmptyStateBody>
|
|
117
|
+
</EmptyState>
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const jobTable = () => (
|
|
121
|
+
<>
|
|
122
|
+
<Table
|
|
123
|
+
aria-label="Task history table"
|
|
124
|
+
borders
|
|
125
|
+
isStriped
|
|
126
|
+
isStickyHeader
|
|
127
|
+
variant="compact"
|
|
128
|
+
>
|
|
129
|
+
<Thead>
|
|
130
|
+
<Tr>
|
|
131
|
+
<Th modifier="wrap">{__('Task Name')}</Th>
|
|
132
|
+
<Th modifier="wrap">{__('Status')}</Th>
|
|
133
|
+
<Th modifier="wrap">{__('Targets')}</Th>
|
|
134
|
+
<Th modifier="wrap">{__('Started')}</Th>
|
|
135
|
+
<Th modifier="wrap">{__('Completed')}</Th>
|
|
136
|
+
<Th modifier="wrap">{__('Duration')}</Th>
|
|
137
|
+
<Th modifier="wrap">{__('Details')}</Th>
|
|
138
|
+
</Tr>
|
|
139
|
+
</Thead>
|
|
140
|
+
<Tbody>
|
|
141
|
+
{taskHistory.map(job => (
|
|
142
|
+
<Tr key={job.job_id}>
|
|
143
|
+
<Td hasRightBorder>
|
|
144
|
+
<TaskPopover
|
|
145
|
+
taskName={job.task_name}
|
|
146
|
+
taskDescription={job.task_description}
|
|
147
|
+
taskParameters={job.task_parameters}
|
|
148
|
+
/>
|
|
149
|
+
</Td>
|
|
150
|
+
<Td hasRightBorder>{getStatusLabel(job.status)}</Td>
|
|
151
|
+
<Td hasRightBorder>
|
|
152
|
+
<HostsPopover targets={job.targets || []} />
|
|
153
|
+
</Td>
|
|
154
|
+
<Td hasRightBorder>{formatDate(job.submitted_at)}</Td>
|
|
155
|
+
<Td hasRightBorder>
|
|
156
|
+
{job.completed_at ? formatDate(job.completed_at) : ''}
|
|
157
|
+
</Td>
|
|
158
|
+
<Td hasRightBorder>{formatDuration(job.duration)}</Td>
|
|
159
|
+
<Td hasRightBorder>
|
|
160
|
+
<a
|
|
161
|
+
href={`${ROUTES.PAGES.TASK_EXECUTION}?proxy_id=${
|
|
162
|
+
job.smart_proxy.id
|
|
163
|
+
}&job_id=${job.job_id}&proxy_name=${encodeURIComponent(
|
|
164
|
+
job.smart_proxy.name
|
|
165
|
+
)}`}
|
|
166
|
+
aria-label={__('View Details')}
|
|
167
|
+
title={__('View Details')}
|
|
168
|
+
>
|
|
169
|
+
<ArrowRightIcon />
|
|
170
|
+
</a>
|
|
171
|
+
</Td>
|
|
172
|
+
</Tr>
|
|
173
|
+
))}
|
|
174
|
+
</Tbody>
|
|
175
|
+
</Table>
|
|
176
|
+
|
|
177
|
+
<Pagination
|
|
178
|
+
itemCount={total}
|
|
179
|
+
perPage={perPage}
|
|
180
|
+
page={page}
|
|
181
|
+
onSetPage={(_event, newPage) => setPage(newPage)}
|
|
182
|
+
onPerPageSelect={(_event, newPerPage) => {
|
|
183
|
+
setPerPage(newPerPage);
|
|
184
|
+
setPage(1);
|
|
185
|
+
}}
|
|
186
|
+
/>
|
|
187
|
+
</>
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
<div className="task-history">
|
|
192
|
+
{isLoadingTaskHistory && spinner()}
|
|
193
|
+
{!isLoadingTaskHistory && taskHistory.length === 0 && noJobs()}
|
|
194
|
+
{!isLoadingTaskHistory && taskHistory.length > 0 && jobTable()}
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export default TaskHistory;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import PropTypes from 'prop-types';
|
|
3
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
4
|
+
import { Popover, Button } from '@patternfly/react-core';
|
|
5
|
+
import { Table, Tbody, Tr, Td } from '@patternfly/react-table';
|
|
6
|
+
|
|
7
|
+
const HostsPopover = ({ targets }) => {
|
|
8
|
+
if (!targets || targets.length === 0) {
|
|
9
|
+
return <>{__('No targets specified')}</>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const popoverContent = (
|
|
13
|
+
<div
|
|
14
|
+
style={{
|
|
15
|
+
maxHeight: '300px',
|
|
16
|
+
overflowY: 'auto',
|
|
17
|
+
border: '1px solid var(--pf-v5-global--BorderColor--100)',
|
|
18
|
+
}}
|
|
19
|
+
>
|
|
20
|
+
<Table variant="compact" borders isStriped>
|
|
21
|
+
<Tbody>
|
|
22
|
+
{targets.map((host, index) => (
|
|
23
|
+
<Tr key={index}>
|
|
24
|
+
<Td className="pf-v5-u-font-family-monospace">{host}</Td>
|
|
25
|
+
</Tr>
|
|
26
|
+
))}
|
|
27
|
+
</Tbody>
|
|
28
|
+
</Table>
|
|
29
|
+
</div>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<Popover bodyContent={popoverContent} position="right" maxWidth="600px">
|
|
34
|
+
<Button variant="link" isInline>
|
|
35
|
+
{targets.length}
|
|
36
|
+
</Button>
|
|
37
|
+
</Popover>
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
HostsPopover.propTypes = {
|
|
42
|
+
targets: PropTypes.arrayOf(PropTypes.string),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
HostsPopover.defaultProps = {
|
|
46
|
+
targets: [],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export default HostsPopover;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Match the actual string coming from smart_proxy_openbolt
|
|
2
|
+
export const STATUS = {
|
|
3
|
+
SUCCESS: 'success',
|
|
4
|
+
FAILURE: 'failure',
|
|
5
|
+
EXCEPTION: 'exception',
|
|
6
|
+
INVALID: 'invalid',
|
|
7
|
+
RUNNING: 'running',
|
|
8
|
+
PENDING: 'pending',
|
|
9
|
+
};
|
|
10
|
+
export const COMPLETED_STATUSES = [
|
|
11
|
+
STATUS.SUCCESS,
|
|
12
|
+
STATUS.FAILURE,
|
|
13
|
+
STATUS.EXCEPTION,
|
|
14
|
+
STATUS.INVALID,
|
|
15
|
+
];
|
|
16
|
+
export const RUNNING_STATUSES = [STATUS.RUNNING, STATUS.PENDING];
|
|
17
|
+
export const ERROR_STATUSES = [
|
|
18
|
+
STATUS.FAILURE,
|
|
19
|
+
STATUS.EXCEPTION,
|
|
20
|
+
STATUS.INVALID,
|
|
21
|
+
];
|
|
22
|
+
export const SUCCESS_STATUSES = [STATUS.SUCCESS];
|
|
23
|
+
|
|
24
|
+
export const POLLING_CONFIG = {
|
|
25
|
+
INTERVAL: 5000, // 5 seconds
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const ENCRYPTED_DEFAULT_PLACEHOLDER = '[Use saved encrypted default]';
|
|
29
|
+
|
|
30
|
+
export const ROUTES = {
|
|
31
|
+
PAGES: {
|
|
32
|
+
LAUNCH_TASK: '/foreman_openbolt/page_launch_task',
|
|
33
|
+
TASK_EXECUTION: '/foreman_openbolt/page_task_execution',
|
|
34
|
+
},
|
|
35
|
+
API: {
|
|
36
|
+
RELOAD_TASKS: '/foreman_openbolt/reload_tasks',
|
|
37
|
+
FETCH_TASKS: '/foreman_openbolt/fetch_tasks',
|
|
38
|
+
FETCH_OPENBOLT_OPTIONS: '/foreman_openbolt/fetch_openbolt_options',
|
|
39
|
+
LAUNCH_TASK: '/foreman_openbolt/launch_task',
|
|
40
|
+
JOB_STATUS: '/foreman_openbolt/job_status',
|
|
41
|
+
JOB_RESULT: '/foreman_openbolt/job_result',
|
|
42
|
+
TASK_HISTORY: '/foreman_openbolt/fetch_task_history',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useCallback } from 'react';
|
|
2
|
+
import { useDispatch } from 'react-redux';
|
|
3
|
+
import { addToast } from 'foremanReact/components/ToastsList';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Custom hook to show messages using the Foreman Toast system.
|
|
7
|
+
*
|
|
8
|
+
* @returns {Function} A function that takes a message and type, and shows a toast message.
|
|
9
|
+
*/
|
|
10
|
+
export const useShowMessage = () => {
|
|
11
|
+
const dispatch = useDispatch();
|
|
12
|
+
|
|
13
|
+
return useCallback(
|
|
14
|
+
(message, type = 'danger') => {
|
|
15
|
+
dispatch(addToast({ type, message }));
|
|
16
|
+
},
|
|
17
|
+
[dispatch]
|
|
18
|
+
);
|
|
19
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { translate as __ } from 'foremanReact/common/I18n';
|
|
3
|
+
import PageLayout from 'foremanReact/routes/common/PageLayout/PageLayout';
|
|
4
|
+
import LaunchTask from '../Components/LaunchTask';
|
|
5
|
+
|
|
6
|
+
const LaunchTaskPage = () => (
|
|
7
|
+
<PageLayout header={__('Launch OpenBolt Task')}>
|
|
8
|
+
<LaunchTask />
|
|
9
|
+
</PageLayout>
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
export default LaunchTaskPage;
|