diff --git a/awx/ui_next/src/components/JobList/useWsJobs.js b/awx/ui_next/src/components/JobList/useWsJobs.js index 46068353af..9c939f25d9 100644 --- a/awx/ui_next/src/components/JobList/useWsJobs.js +++ b/awx/ui_next/src/components/JobList/useWsJobs.js @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; -import useThrottle from './useThrottle'; +import useThrottle from '../../util/useThrottle'; import { parseQueryString } from '../../util/qs'; import sortJobs from './sortJobs'; diff --git a/awx/ui_next/src/components/JobList/useWsJobs.test.jsx b/awx/ui_next/src/components/JobList/useWsJobs.test.jsx index c7782b2427..77e5b714a3 100644 --- a/awx/ui_next/src/components/JobList/useWsJobs.test.jsx +++ b/awx/ui_next/src/components/JobList/useWsJobs.test.jsx @@ -8,7 +8,7 @@ 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', () => ({ +jest.mock('../../util/useThrottle', () => ({ __esModule: true, default: jest.fn(val => val), })); @@ -90,6 +90,7 @@ describe('useWsJobs hook', () => { mockServer.send( JSON.stringify({ unified_job_id: 1, + type: 'job', status: 'successful', }) ); @@ -116,6 +117,7 @@ describe('useWsJobs hook', () => { mockServer.send( JSON.stringify({ unified_job_id: 2, + type: 'job', status: 'running', }) ); diff --git a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx index e82a31c4ae..a39f246205 100644 --- a/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx +++ b/awx/ui_next/src/screens/Project/ProjectList/ProjectList.jsx @@ -13,6 +13,7 @@ import PaginatedDataList, { ToolbarAddButton, ToolbarDeleteButton, } from '../../../components/PaginatedDataList'; +import useWsProjects from './useWsProjects'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import ProjectListItem from './ProjectListItem'; @@ -29,7 +30,7 @@ function ProjectList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { projects, itemCount, actions }, + result: { results, itemCount, actions }, error: contentError, isLoading, request: fetchProjects, @@ -41,13 +42,13 @@ function ProjectList({ i18n }) { ProjectsAPI.readOptions(), ]); return { - projects: response.data.results, + results: response.data.results, itemCount: response.data.count, actions: actionsResponse.data.actions, }; }, [location]), { - projects: [], + results: [], itemCount: 0, actions: {}, } @@ -57,6 +58,8 @@ function ProjectList({ i18n }) { fetchProjects(); }, [fetchProjects]); + const projects = useWsProjects(results); + const isAllSelected = selected.length === projects.length && selected.length > 0; const { diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js new file mode 100644 index 0000000000..a5c46319ba --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.js @@ -0,0 +1,85 @@ +import { useState, useEffect, useRef } from 'react'; + +export default function useWsProjects(initialProjects) { + const [projects, setProjects] = useState(initialProjects); + const [lastMessage, setLastMessage] = useState(null); + const ws = useRef(null); + + useEffect(() => { + setProjects(initialProjects); + }, [initialProjects]); + + useEffect(() => { + if (!lastMessage?.unified_job_id || lastMessage.type !== 'project_update') { + return; + } + const index = projects.findIndex(p => p.id === lastMessage.project_id); + if (index === -1) { + return; + } + + const project = projects[index]; + const updatedProject = { + ...project, + summary_fields: { + ...project.summary_fields, + last_job: { + id: lastMessage.unified_job_id, + status: lastMessage.status, + finished: lastMessage.finished, + }, + }, + }; + setProjects([ + ...projects.slice(0, index), + updatedProject, + ...projects.slice(index + 1), + ]); + }, [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'], + 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 projects; +} diff --git a/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx new file mode 100644 index 0000000000..e32a31ab70 --- /dev/null +++ b/awx/ui_next/src/screens/Project/ProjectList/useWsProjects.test.jsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsProjects from './useWsProjects'; + +function TestInner() { + return
; +} +function Test({ projects }) { + const synced = useWsProjects(projects); + return ; +} + +describe('useWsProjects', () => { + 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 projects list', async () => { + const projects = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + expect(wrapper.find('TestInner').prop('projects')).toEqual(projects); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const projects = [{ id: 1 }]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + WS.clean(); + }); + + test('should update project status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const projects = [ + { + id: 1, + summary_fields: { + last_job: { + id: 1, + status: 'running', + finished: null, + }, + }, + }, + ]; + await act(async () => { + wrapper = await mountWithContexts(); + }); + + await mockServer.connected; + await expect(mockServer).toReceiveMessage( + JSON.stringify({ + xrftoken: 'abc123', + groups: { + jobs: ['status_changed'], + control: ['limit_reached_1'], + }, + }) + ); + expect( + wrapper.find('TestInner').prop('projects')[0].summary_fields.last_job + .status + ).toEqual('running'); + await act(async () => { + mockServer.send( + JSON.stringify({ + project_id: 1, + unified_job_id: 12, + type: 'project_update', + status: 'successful', + finished: '2020-07-02T16:28:31.839071Z', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('projects')[0].summary_fields.last_job + ).toEqual({ + id: 12, + status: 'successful', + finished: '2020-07-02T16:28:31.839071Z', + }); + WS.clean(); + }); +}); diff --git a/awx/ui_next/src/components/JobList/useThrottle.js b/awx/ui_next/src/util/useThrottle.js similarity index 100% rename from awx/ui_next/src/components/JobList/useThrottle.js rename to awx/ui_next/src/util/useThrottle.js