diff --git a/.gitignore b/.gitignore index 2f3635eabe..406382197f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ awx/ui/templates/ui/installing.html awx/ui_next/node_modules/ awx/ui_next/coverage/ awx/ui_next/build +awx/ui_next/.env.local rsyslog.pid /tower-license /tower-license/** diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index 049b48ad8d..12df9b3259 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -8988,6 +8988,12 @@ } } }, + "jest-websocket-mock": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jest-websocket-mock/-/jest-websocket-mock-2.0.2.tgz", + "integrity": "sha512-SFTUI8O/LDGqROOMnfAzbrrX5gQ8GDhRqkzVrt8Y67evnFKccRPFI3ymS05tKcMONvVfxumat4pX/LRjM/CjVg==", + "dev": true + }, "jest-worker": { "version": "24.9.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-24.9.0.tgz", @@ -9893,6 +9899,15 @@ "minimist": "^1.2.5" } }, + "mock-socket": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.0.3.tgz", + "integrity": "sha512-SxIiD2yE/By79p3cNAAXyLQWTvEFNEzcAO7PH+DzRqKSFaplAPFjiQLmw8ofmpCsZf+Rhfn2/xCJagpdGmYdTw==", + "dev": true, + "requires": { + "url-parse": "^1.4.4" + } + }, "moo": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", diff --git a/awx/ui_next/package.json b/awx/ui_next/package.json index e5ca6eb7f2..74f857ce63 100644 --- a/awx/ui_next/package.json +++ b/awx/ui_next/package.json @@ -72,6 +72,8 @@ "eslint-plugin-react": "^7.11.1", "eslint-plugin-react-hooks": "^2.2.0", "http-proxy-middleware": "^1.0.3", + "jest-websocket-mock": "^2.0.2", + "mock-socket": "^9.0.3", "prettier": "^1.18.2" }, "jest": { diff --git a/awx/ui_next/src/components/JobList/JobList.jsx b/awx/ui_next/src/components/JobList/JobList.jsx index 403f5bfc9a..6e6d57653b 100644 --- a/awx/ui_next/src/components/JobList/JobList.jsx +++ b/awx/ui_next/src/components/JobList/JobList.jsx @@ -11,6 +11,7 @@ import PaginatedDataList, { ToolbarDeleteButton } from '../PaginatedDataList'; import useRequest, { useDeleteItems } from '../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../util/qs'; import JobListItem from './JobListItem'; +import useWsJobs from './useWsJobs'; import { AdHocCommandsAPI, InventoryUpdatesAPI, @@ -36,35 +37,39 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { const [selected, setSelected] = useState([]); const location = useLocation(); - const { - result: { jobs, itemCount }, + result: { results, count }, error: contentError, isLoading, request: fetchJobs, } = useRequest( - useCallback(async () => { - const params = parseQueryString(QS_CONFIG, location.search); - - const { - data: { count, results }, - } = await UnifiedJobsAPI.read({ ...params }); - - return { - itemCount: count, - jobs: results, - }; - }, [location]), // eslint-disable-line react-hooks/exhaustive-deps - { - jobs: [], - itemCount: 0, - } + useCallback( + async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const { data } = await UnifiedJobsAPI.read({ ...params }); + return data; + }, + [location] // eslint-disable-line react-hooks/exhaustive-deps + ), + { results: [], count: 0 } ); - useEffect(() => { fetchJobs(); }, [fetchJobs]); + // TODO: update QS_CONFIG to be safe for deps array + const fetchJobsById = useCallback( + async ids => { + const params = parseQueryString(QS_CONFIG, location.search); + params.id__in = ids.join(','); + const { data } = await UnifiedJobsAPI.read(params); + return data.results; + }, + [location.search] // eslint-disable-line react-hooks/exhaustive-deps + ); + + const jobs = useWsJobs(results, fetchJobsById, QS_CONFIG); + const isAllSelected = selected.length === jobs.length && selected.length > 0; const { isLoading: isDeleteLoading, @@ -125,7 +130,7 @@ function JobList({ i18n, defaultParams, showTypeColumn = false }) { contentError={contentError} hasContentLoading={isLoading || isDeleteLoading} items={jobs} - itemCount={itemCount} + itemCount={count} pluralizedItemName={i18n._(t`Jobs`)} qsConfig={QS_CONFIG} onRowClick={handleSelect} diff --git a/awx/ui_next/src/components/JobList/JobList.test.jsx b/awx/ui_next/src/components/JobList/JobList.test.jsx index f2d1e6f71d..31bae1ed95 100644 --- a/awx/ui_next/src/components/JobList/JobList.test.jsx +++ b/awx/ui_next/src/components/JobList/JobList.test.jsx @@ -105,6 +105,16 @@ function waitForLoaded(wrapper) { } describe('', () => { + let debug; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + test('initially renders succesfully', async () => { let wrapper; await act(async () => { diff --git a/awx/ui_next/src/components/JobList/JobListItem.jsx b/awx/ui_next/src/components/JobList/JobListItem.jsx index 296ecba9c7..a813641a7f 100644 --- a/awx/ui_next/src/components/JobList/JobListItem.jsx +++ b/awx/ui_next/src/components/JobList/JobListItem.jsx @@ -75,7 +75,7 @@ function JobListItem({ ] : []), - {formatDateString(job.finished)} + {job.finished ? formatDateString(job.finished) : ''} , ]} /> diff --git a/awx/ui_next/src/components/JobList/sortJobs.js b/awx/ui_next/src/components/JobList/sortJobs.js new file mode 100644 index 0000000000..92218994ae --- /dev/null +++ b/awx/ui_next/src/components/JobList/sortJobs.js @@ -0,0 +1,78 @@ +const sortFns = { + finished: byFinished, + id: byId, + name: byName, + created_by__id: byCreatedBy, + unified_job_template__project__id: byProject, + started: byStarted, +}; + +export default function sortJobs(jobs, params) { + const { order_by = '-finished', page_size = 20 } = params; + const key = order_by.replace('-', ''); + const fn = sortFns[key]; + if (!fn) { + return jobs.slice(0, page_size); + } + + const sorted = order_by[0] === '-' ? jobs.sort(reverse(fn)) : jobs.sort(fn); + return sorted.slice(0, page_size); +} + +function reverse(fn) { + return (a, b) => fn(a, b) * -1; +} + +function byFinished(a, b) { + if (!a.finished) { + return 1; + } + if (!b.finished) { + return -1; + } + return sort(new Date(a.finished), new Date(b.finished)); +} + +function byStarted(a, b) { + if (!a.started) { + return 1; + } + if (!b.started) { + return -1; + } + return sort(new Date(a.started), new Date(b.started)); +} + +function byId(a, b) { + return sort(a.id, b.id); +} + +function byName(a, b) { + return sort(a.name, b.name); +} + +function byCreatedBy(a, b) { + const nameA = a.summary_fields?.created_by?.id; + const nameB = b.summary_fields?.created_by?.id; + return sort(nameA, nameB) * -1; +} + +function byProject(a, b) { + return sort(a.unified_job_template, b.unified_job_template); +} + +function sort(a, b) { + if (!a) { + return -1; + } + if (!b) { + return 1; + } + if (a < b) { + return -1; + } + if (a > b) { + return 1; + } + return 0; +} diff --git a/awx/ui_next/src/components/JobList/useThrottle.js b/awx/ui_next/src/components/JobList/useThrottle.js new file mode 100644 index 0000000000..cfdedfecfc --- /dev/null +++ b/awx/ui_next/src/components/JobList/useThrottle.js @@ -0,0 +1,21 @@ +import { useState, useEffect, useRef } from 'react'; + +export default function useThrottle(value, limit) { + const [throttledValue, setThrottledValue] = useState(value); + const lastRan = useRef(Date.now()); + + useEffect(() => { + const handler = setTimeout(() => { + if (Date.now() - lastRan.current >= limit) { + setThrottledValue(value); + lastRan.current = Date.now(); + } + }, limit - (Date.now() - lastRan.current)); + + return () => { + clearTimeout(handler); + }; + }, [value, limit]); + + return throttledValue; +} diff --git a/awx/ui_next/src/components/JobList/useWsJobs.js b/awx/ui_next/src/components/JobList/useWsJobs.js new file mode 100644 index 0000000000..46068353af --- /dev/null +++ b/awx/ui_next/src/components/JobList/useWsJobs.js @@ -0,0 +1,119 @@ +import { useState, useEffect, useRef } from 'react'; +import { useLocation } from 'react-router-dom'; +import useThrottle from './useThrottle'; +import { parseQueryString } from '../../util/qs'; +import sortJobs from './sortJobs'; + +export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) { + const location = useLocation(); + const [jobs, setJobs] = useState(initialJobs); + const [lastMessage, setLastMessage] = useState(null); + const [jobsToFetch, setJobsToFetch] = useState([]); + const throttledJobsToFetch = useThrottle(jobsToFetch, 5000); + + useEffect(() => { + setJobs(initialJobs); + }, [initialJobs]); + + const enqueueJobId = id => { + if (!jobsToFetch.includes(id)) { + setJobsToFetch(ids => ids.concat(id)); + } + }; + useEffect(() => { + (async () => { + if (!throttledJobsToFetch.length) { + return; + } + setJobsToFetch([]); + const newJobs = await fetchJobsById(throttledJobsToFetch); + const deduplicated = newJobs.filter( + job => !jobs.find(j => j.id === job.id) + ); + if (deduplicated.length) { + const params = parseQueryString(qsConfig, location.search); + setJobs(sortJobs([...deduplicated, ...jobs], params)); + } + })(); + }, [throttledJobsToFetch, fetchJobsById]); // eslint-disable-line react-hooks/exhaustive-deps + + const ws = useRef(null); + + useEffect(() => { + if (!lastMessage || !lastMessage.unified_job_id) { + return; + } + const params = parseQueryString(qsConfig, location.search); + const filtersApplied = Object.keys(params).length > 4; + if ( + filtersApplied && + !['completed', 'failed', 'error'].includes(lastMessage.status) + ) { + return; + } + + const jobId = lastMessage.unified_job_id; + const index = jobs.findIndex(j => j.id === jobId); + if (index > -1) { + setJobs(sortJobs(updateJob(jobs, index, lastMessage), params)); + } else { + enqueueJobId(lastMessage.unified_job_id); + } + }, [lastMessage]); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + ws.current = new WebSocket(`wss://${window.location.host}/websocket/`); + + const connect = () => { + const xrftoken = `; ${document.cookie}` + .split('; csrftoken=') + .pop() + .split(';') + .shift(); + ws.current.send( + JSON.stringify({ + xrftoken, + groups: { + jobs: ['status_changed'], + schedules: ['changed'], + control: ['limit_reached_1'], + }, + }) + ); + }; + ws.current.onopen = connect; + + ws.current.onmessage = e => { + setLastMessage(JSON.parse(e.data)); + }; + + ws.current.onclose = e => { + // eslint-disable-next-line no-console + console.debug('Socket closed. Reconnecting...', e); + setTimeout(() => { + connect(); + }, 1000); + }; + + ws.current.onerror = err => { + // eslint-disable-next-line no-console + console.debug('Socket error: ', err, 'Disconnecting...'); + ws.current.close(); + }; + + return () => { + ws.current.close(); + }; + }, []); + + return jobs; +} + +function updateJob(jobs, index, message) { + const job = { + ...jobs[index], + status: message.status, + finished: message.finished, + }; + return [...jobs.slice(0, index), job, ...jobs.slice(index + 1)]; +} diff --git a/awx/ui_next/src/components/JobList/useWsJobs.test.jsx b/awx/ui_next/src/components/JobList/useWsJobs.test.jsx new file mode 100644 index 0000000000..c7782b2427 --- /dev/null +++ b/awx/ui_next/src/components/JobList/useWsJobs.test.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import useWsJobs from './useWsJobs'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('./useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return
; +} +function Test({ jobs, fetch }) { + const qsConfig = {}; + const syncedJobs = useWsJobs(jobs, fetch, qsConfig); + return ; +} + +describe('useWsJobs hook', () => { + let debug; + let wrapper; + beforeEach(() => { + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; + }); + + afterEach(() => { + global.console.debug = debug; + }); + + test('should return jobs list', () => { + const jobs = [{ id: 1 }]; + wrapper = mountWithContexts(); + + expect(wrapper.find('TestInner').prop('jobs')).toEqual(jobs); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const jobs = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + schedules: ['changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const jobs = [{ id: 1, status: 'running' }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + schedules: ['changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect(wrapper.find('TestInner').prop('jobs')[0].status).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_id: 1, + status: 'successful', + }) + ); + }); + wrapper.update(); + + expect(wrapper.find('TestInner').prop('jobs')[0].status).toEqual( + 'successful' + ); + WS.clean(); + }); + + test('should fetch new job', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + const jobs = [{ id: 1 }]; + const fetch = jest.fn(() => []); + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_id: 2, + status: 'running', + }) + ); + }); + + expect(fetch).toHaveBeenCalledWith([2]); + WS.clean(); + }); +});