From 981c9527b279e3854224c10f53675071a99f1f55 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 10 Jul 2020 15:55:12 -0700 Subject: [PATCH 1/3] add template list websocket support --- .../Template/TemplateList/TemplateList.jsx | 18 +-- .../Template/TemplateList/useWsTemplates.js | 112 ++++++++++++++++++ 2 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx index 5be5cf580f..6e4a831afa 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.jsx @@ -17,7 +17,7 @@ import PaginatedDataList, { } from '../../../components/PaginatedDataList'; import useRequest, { useDeleteItems } from '../../../util/useRequest'; import { getQSConfig, parseQueryString } from '../../../util/qs'; - +import useWsTemplates from './useWsTemplates'; import AddDropDownButton from '../../../components/AddDropDownButton'; import TemplateListItem from './TemplateListItem'; @@ -36,27 +36,27 @@ function TemplateList({ i18n }) { const [selected, setSelected] = useState([]); const { - result: { templates, count, jtActions, wfjtActions }, + result: { results, count, jtActions, wfjtActions }, error: contentError, isLoading, request: fetchTemplates, } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, location.search); - const results = await Promise.all([ + const responses = await Promise.all([ UnifiedJobTemplatesAPI.read(params), JobTemplatesAPI.readOptions(), WorkflowJobTemplatesAPI.readOptions(), ]); return { - templates: results[0].data.results, - count: results[0].data.count, - jtActions: results[1].data.actions, - wfjtActions: results[2].data.actions, + results: responses[0].data.results, + count: responses[0].data.count, + jtActions: responses[1].data.actions, + wfjtActions: responses[2].data.actions, }; }, [location]), { - templates: [], + results: [], count: 0, jtActions: {}, wfjtActions: {}, @@ -67,6 +67,8 @@ function TemplateList({ i18n }) { fetchTemplates(); }, [fetchTemplates]); + const templates = useWsTemplates(results); + const isAllSelected = selected.length === templates.length && selected.length > 0; const { diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js new file mode 100644 index 0000000000..052d2a9197 --- /dev/null +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js @@ -0,0 +1,112 @@ +import { useState, useEffect, useRef } from 'react'; + +export default function useWsTemplates(initialTemplates) { + const [templates, setTemplates] = useState(initialTemplates); + + const [lastMessage, setLastMessage] = useState(null); + const ws = useRef(null); + + useEffect(() => { + setTemplates(initialTemplates); + }, [initialTemplates]); + + // x = { + // unified_job_id: 548, + // status: 'pending', + // type: 'job', + // group_name: 'jobs', + // unified_job_template_id: 26, + // }; + useEffect( + function parseWsMessage() { + if (!lastMessage?.unified_job_id) { + return; + } + const index = templates.findIndex( + t => t.id === lastMessage.unified_job_template_id + ); + if (index === -1) { + return; + } + + const template = templates[index]; + const updated = [...templates]; + updated[index] = updateTemplate(template, lastMessage); + setTemplates(updated); + }, + [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 templates; +} + +function updateTemplate(template, message) { + const recentJobs = [...(template.summary_fields.recent_jobs || [])]; + const job = { + id: message.unified_job_id, + status: message.status, + finished: message.finished, + type: message.type, + }; + const index = recentJobs.findIndex(j => j.id === job.id); + if (index > -1) { + recentJobs[index] = { + ...recentJobs[index], + ...job, + }; + } else { + recentJobs.unshift(job); + } + + return { + ...template, + summary_fields: { + ...template.summary_fields, + recent_jobs: recentJobs.slice(0, 10), + }, + }; +} From b76783791acda952512f8c99975c586998e22647 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 13 Jul 2020 10:39:37 -0700 Subject: [PATCH 2/3] add useWsTemplates tests --- .../TemplateList/TemplateList.test.jsx | 4 + .../Template/TemplateList/useWsTemplates.js | 9 +- .../TemplateList/useWsTemplates.test.jsx | 193 ++++++++++++++++++ 3 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx diff --git a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx index b04ef9c35b..377e87583e 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx +++ b/awx/ui_next/src/screens/Template/TemplateList/TemplateList.test.jsx @@ -75,6 +75,7 @@ const mockTemplates = [ ]; describe('', () => { + let debug; beforeEach(() => { UnifiedJobTemplatesAPI.read.mockResolvedValue({ data: { @@ -88,10 +89,13 @@ describe('', () => { actions: [], }, }); + debug = global.console.debug; // eslint-disable-line prefer-destructuring + global.console.debug = () => {}; }); afterEach(() => { jest.clearAllMocks(); + global.console.debug = debug; }); test('initially renders successfully', async () => { diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js index 052d2a9197..42863dfdaf 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js @@ -10,13 +10,6 @@ export default function useWsTemplates(initialTemplates) { setTemplates(initialTemplates); }, [initialTemplates]); - // x = { - // unified_job_id: 548, - // status: 'pending', - // type: 'job', - // group_name: 'jobs', - // unified_job_template_id: 26, - // }; useEffect( function parseWsMessage() { if (!lastMessage?.unified_job_id) { @@ -89,7 +82,7 @@ function updateTemplate(template, message) { const job = { id: message.unified_job_id, status: message.status, - finished: message.finished, + finished: message.finished || null, type: message.type, }; const index = recentJobs.findIndex(j => j.id === job.id); diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx new file mode 100644 index 0000000000..61bc6e042e --- /dev/null +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.test.jsx @@ -0,0 +1,193 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import WS from 'jest-websocket-mock'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import useWsTemplates from './useWsTemplates'; + +/* + Jest mock timers don’t play well with jest-websocket-mock, + so we'll stub out throttling to resolve immediately +*/ +jest.mock('../../../util/useThrottle', () => ({ + __esModule: true, + default: jest.fn(val => val), +})); + +function TestInner() { + return
; +} +function Test({ templates }) { + const syncedTemplates = useWsTemplates(templates); + return ; +} + +describe('useWsTemplates 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 templates list', () => { + const templates = [{ id: 1 }]; + wrapper = mountWithContexts(); + + expect(wrapper.find('TestInner').prop('templates')).toEqual(templates); + WS.clean(); + }); + + test('should establish websocket connection', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [{ 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 recent job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [ + { + id: 1, + summary_fields: { + recent_jobs: [ + { + id: 10, + type: 'job', + status: 'running', + }, + { + id: 11, + type: 'job', + status: 'successful', + }, + ], + }, + }, + ]; + 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('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_template_id: 1, + unified_job_id: 10, + type: 'job', + status: 'successful', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('successful'); + WS.clean(); + }); + + test('should add new job status', async () => { + global.document.cookie = 'csrftoken=abc123'; + const mockServer = new WS('wss://localhost/websocket/'); + + const templates = [ + { + id: 1, + summary_fields: { + recent_jobs: [ + { + id: 10, + type: 'job', + status: 'running', + }, + { + id: 11, + type: 'job', + status: 'successful', + }, + ], + }, + }, + ]; + 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('templates')[0].summary_fields + .recent_jobs[0].status + ).toEqual('running'); + act(() => { + mockServer.send( + JSON.stringify({ + unified_job_template_id: 1, + unified_job_id: 13, + type: 'job', + status: 'running', + }) + ); + }); + wrapper.update(); + + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields.recent_jobs + ).toHaveLength(3); + expect( + wrapper.find('TestInner').prop('templates')[0].summary_fields + .recent_jobs[0] + ).toEqual({ + id: 13, + status: 'running', + finished: null, + type: 'job', + }); + WS.clean(); + }); +}); From 350c5854998d58a789f961e0ea3e76e373a0ce43 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Mon, 13 Jul 2020 13:32:13 -0700 Subject: [PATCH 3/3] update useWsTemplates to use useWebsocket hook --- .../Template/TemplateList/useWsTemplates.js | 54 +++---------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js index 42863dfdaf..fa10424c85 100644 --- a/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js +++ b/awx/ui_next/src/screens/Template/TemplateList/useWsTemplates.js @@ -1,10 +1,12 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect } from 'react'; +import useWebsocket from '../../../util/useWebsocket'; export default function useWsTemplates(initialTemplates) { const [templates, setTemplates] = useState(initialTemplates); - - const [lastMessage, setLastMessage] = useState(null); - const ws = useRef(null); + const lastMessage = useWebsocket({ + jobs: ['status_changed'], + control: ['limit_reached_1'], + }); useEffect(() => { setTemplates(initialTemplates); @@ -30,50 +32,6 @@ export default function useWsTemplates(initialTemplates) { [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 templates; }