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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +619 -0
  3. data/README.md +46 -0
  4. data/Rakefile +106 -0
  5. data/app/controllers/foreman_openbolt/task_controller.rb +298 -0
  6. data/app/lib/actions/foreman_openbolt/cleanup_proxy_artifacts.rb +40 -0
  7. data/app/lib/actions/foreman_openbolt/poll_task_status.rb +151 -0
  8. data/app/models/foreman_openbolt/task_job.rb +110 -0
  9. data/app/views/foreman_openbolt/react_page.html.erb +1 -0
  10. data/config/routes.rb +24 -0
  11. data/db/migrate/20250819000000_create_openbolt_task_jobs.rb +25 -0
  12. data/db/migrate/20250925000000_add_command_to_openbolt_task_jobs.rb +7 -0
  13. data/db/migrate/20251001000000_add_task_description_to_task_jobs.rb +7 -0
  14. data/db/seeds.d/001_add_openbolt_feature.rb +4 -0
  15. data/lib/foreman_openbolt/engine.rb +169 -0
  16. data/lib/foreman_openbolt/version.rb +5 -0
  17. data/lib/foreman_openbolt.rb +7 -0
  18. data/lib/proxy_api/openbolt.rb +53 -0
  19. data/lib/tasks/foreman_openbolt_tasks.rake +48 -0
  20. data/locale/Makefile +73 -0
  21. data/locale/en/foreman_openbolt.po +19 -0
  22. data/locale/foreman_openbolt.pot +19 -0
  23. data/locale/gemspec.rb +7 -0
  24. data/package.json +41 -0
  25. data/test/factories/foreman_openbolt_factories.rb +7 -0
  26. data/test/test_plugin_helper.rb +8 -0
  27. data/test/unit/foreman_openbolt_test.rb +13 -0
  28. data/webpack/global_index.js +4 -0
  29. data/webpack/global_test_setup.js +11 -0
  30. data/webpack/index.js +19 -0
  31. data/webpack/src/Components/LaunchTask/EmptyContent.js +24 -0
  32. data/webpack/src/Components/LaunchTask/FieldTable.js +147 -0
  33. data/webpack/src/Components/LaunchTask/HostSelector/HostSearch.js +29 -0
  34. data/webpack/src/Components/LaunchTask/HostSelector/SearchSelect.js +208 -0
  35. data/webpack/src/Components/LaunchTask/HostSelector/SelectedChips.js +113 -0
  36. data/webpack/src/Components/LaunchTask/HostSelector/hostgroups.gql +9 -0
  37. data/webpack/src/Components/LaunchTask/HostSelector/hosts.gql +10 -0
  38. data/webpack/src/Components/LaunchTask/HostSelector/index.js +261 -0
  39. data/webpack/src/Components/LaunchTask/OpenBoltOptionsSection.js +116 -0
  40. data/webpack/src/Components/LaunchTask/ParameterField.js +145 -0
  41. data/webpack/src/Components/LaunchTask/ParametersSection.js +66 -0
  42. data/webpack/src/Components/LaunchTask/SmartProxySelect.js +51 -0
  43. data/webpack/src/Components/LaunchTask/TaskSelect.js +84 -0
  44. data/webpack/src/Components/LaunchTask/hooks/useOpenBoltOptions.js +63 -0
  45. data/webpack/src/Components/LaunchTask/hooks/useSmartProxies.js +48 -0
  46. data/webpack/src/Components/LaunchTask/hooks/useTasksData.js +64 -0
  47. data/webpack/src/Components/LaunchTask/index.js +333 -0
  48. data/webpack/src/Components/TaskExecution/ExecutionDetails.js +188 -0
  49. data/webpack/src/Components/TaskExecution/ExecutionDisplay.js +99 -0
  50. data/webpack/src/Components/TaskExecution/LoadingIndicator.js +51 -0
  51. data/webpack/src/Components/TaskExecution/ResultDisplay.js +174 -0
  52. data/webpack/src/Components/TaskExecution/TaskDetails.js +99 -0
  53. data/webpack/src/Components/TaskExecution/hooks/useJobPolling.js +142 -0
  54. data/webpack/src/Components/TaskExecution/index.js +130 -0
  55. data/webpack/src/Components/TaskHistory/TaskPopover.js +95 -0
  56. data/webpack/src/Components/TaskHistory/index.js +199 -0
  57. data/webpack/src/Components/common/HostsPopover.js +49 -0
  58. data/webpack/src/Components/common/constants.js +44 -0
  59. data/webpack/src/Components/common/helpers.js +19 -0
  60. data/webpack/src/Pages/LaunchTaskPage.js +12 -0
  61. data/webpack/src/Pages/TaskExecutionPage.js +12 -0
  62. data/webpack/src/Pages/TaskHistoryPage.js +12 -0
  63. data/webpack/src/Router/routes.js +30 -0
  64. data/webpack/test_setup.js +17 -0
  65. data/webpack/webpack.config.js +7 -0
  66. 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;