Merge pull request #7598 from keithjgrant/6618-websocket-inventories-list

Add Websocket support to inventories list

Reviewed-by: Daniel Sami
             https://github.com/dsesami
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-07-13 20:25:49 +00:00
committed by GitHub
10 changed files with 359 additions and 104 deletions

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import useWebsocket from '../../util/useWebsocket';
import useThrottle from '../../util/useThrottle';
import { parseQueryString } from '../../util/qs';
import sortJobs from './sortJobs';
@@ -7,9 +8,13 @@ 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);
const lastMessage = useWebsocket({
jobs: ['status_changed'],
schedules: ['changed'],
control: ['limit_reached_1'],
});
useEffect(() => {
setJobs(initialJobs);
@@ -37,8 +42,6 @@ export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) {
})();
}, [throttledJobsToFetch, fetchJobsById]); // eslint-disable-line react-hooks/exhaustive-deps
const ws = useRef(null);
useEffect(() => {
if (!lastMessage || !lastMessage.unified_job_id) {
return;
@@ -61,51 +64,6 @@ export default function useWsJobs(initialJobs, fetchJobsById, qsConfig) {
}
}, [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;
}

View File

@@ -0,0 +1,46 @@
import React from 'react';
import 'styled-components/macro';
import styled, { keyframes } from 'styled-components';
import { oneOf, string } from 'prop-types';
import { CloudIcon } from '@patternfly/react-icons';
const COLORS = {
success: '--pf-global--palette--green-400',
syncing: '--pf-global--palette--green-400',
error: '--pf-global--danger-color--100',
disabled: '--pf-global--disabled-color--200',
};
const Pulse = keyframes`
from {
opacity: 0;
}
to {
opacity: 1.0;
}
`;
const PulseWrapper = styled.div`
animation: ${Pulse} 1.5s linear infinite alternate;
`;
export default function SyncStatusIndicator({ status, title }) {
const color = COLORS[status] || COLORS.disabled;
if (status === 'syncing') {
return (
<PulseWrapper>
<CloudIcon color={`var(${color})`} title={title} />
</PulseWrapper>
);
}
return <CloudIcon color={`var(${color})`} title={title} />;
}
SyncStatusIndicator.propTypes = {
status: oneOf(['success', 'error', 'disabled', 'syncing']).isRequired,
title: string,
};
SyncStatusIndicator.defaultProps = {
title: null,
};

View File

@@ -0,0 +1 @@
export { default } from './SyncStatusIndicator';

View File

@@ -1,7 +1,6 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useLocation, useRouteMatch } from 'react-router-dom';
import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro';
import { Card, PageSection } from '@patternfly/react-core';
@@ -13,8 +12,8 @@ import ErrorDetail from '../../../components/ErrorDetail';
import PaginatedDataList, {
ToolbarDeleteButton,
} from '../../../components/PaginatedDataList';
import { getQSConfig, parseQueryString } from '../../../util/qs';
import useWsInventories from './useWsInventories';
import AddDropDownButton from '../../../components/AddDropDownButton';
import InventoryListItem from './InventoryListItem';
@@ -30,7 +29,7 @@ function InventoryList({ i18n }) {
const [selected, setSelected] = useState([]);
const {
result: { inventories, itemCount, actions },
result: { results, itemCount, actions },
error: contentError,
isLoading,
request: fetchInventories,
@@ -42,13 +41,13 @@ function InventoryList({ i18n }) {
InventoriesAPI.readOptions(),
]);
return {
inventories: response.data.results,
results: response.data.results,
itemCount: response.data.count,
actions: actionsResponse.data.actions,
};
}, [location]),
{
inventories: [],
results: [],
itemCount: 0,
actions: {},
}
@@ -58,6 +57,17 @@ function InventoryList({ i18n }) {
fetchInventories();
}, [fetchInventories]);
const fetchInventoriesById = useCallback(
async ids => {
const params = parseQueryString(QS_CONFIG, location.search);
params.id__in = ids.join(',');
const { data } = await InventoriesAPI.read(params);
return data.results;
},
[location.search] // eslint-disable-line react-hooks/exhaustive-deps
);
const inventories = useWsInventories(results, fetchInventoriesById);
const isAllSelected =
selected.length === inventories.length && selected.length > 0;
const {

View File

@@ -119,6 +119,7 @@ const mockInventories = [
];
describe('<InventoryList />', () => {
let debug;
beforeEach(() => {
InventoriesAPI.read.mockResolvedValue({
data: {
@@ -135,10 +136,13 @@ describe('<InventoryList />', () => {
},
},
});
debug = global.console.debug; // eslint-disable-line prefer-destructuring
global.console.debug = () => {};
});
afterEach(() => {
jest.clearAllMocks();
global.console.debug = debug;
});
test('should load and render inventories', async () => {

View File

@@ -10,16 +10,16 @@ import {
DataListItemRow,
Tooltip,
} from '@patternfly/react-core';
import { PencilAltIcon } from '@patternfly/react-icons';
import { t } from '@lingui/macro';
import { Link } from 'react-router-dom';
import styled from 'styled-components';
import { PencilAltIcon } from '@patternfly/react-icons';
import { timeOfDay } from '../../../util/dates';
import { InventoriesAPI } from '../../../api';
import { Inventory } from '../../../types';
import DataListCell from '../../../components/DataListCell';
import CopyButton from '../../../components/CopyButton';
import SyncStatusIndicator from '../../../components/SyncStatusIndicator';
const DataListAction = styled(_DataListAction)`
align-items: center;
@@ -52,6 +52,14 @@ function InventoryListItem({
}, [inventory.id, inventory.name, fetchInventories]);
const labelId = `check-action-${inventory.id}`;
let syncStatus = 'disabled';
if (inventory.isSourceSyncRunning) {
syncStatus = 'syncing';
} else if (inventory.has_inventory_sources) {
syncStatus =
inventory.inventory_sources_with_failures > 0 ? 'error' : 'success';
}
return (
<DataListItem
key={inventory.id}
@@ -67,7 +75,10 @@ function InventoryListItem({
/>
<DataListItemCells
dataListCells={[
<DataListCell key="divider">
<DataListCell key="sync-status" isIcon>
<SyncStatusIndicator status={syncStatus} />
</DataListCell>,
<DataListCell key="name">
<Link to={`${detailUrl}`}>
<b>{inventory.name}</b>
</Link>

View File

@@ -0,0 +1,88 @@
import { useState, useEffect } from 'react';
import useWebsocket from '../../../util/useWebsocket';
import useThrottle from '../../../util/useThrottle';
export default function useWsProjects(
initialInventories,
fetchInventoriesById
) {
const [inventories, setInventories] = useState(initialInventories);
const [inventoriesToFetch, setInventoriesToFetch] = useState([]);
const throttledInventoriesToFetch = useThrottle(inventoriesToFetch, 5000);
const lastMessage = useWebsocket({
inventories: ['status_changed'],
jobs: ['status_changed'],
control: ['limit_reached_1'],
});
useEffect(() => {
setInventories(initialInventories);
}, [initialInventories]);
const enqueueId = id => {
if (!inventoriesToFetch.includes(id)) {
setInventoriesToFetch(ids => ids.concat(id));
}
};
useEffect(
function fetchUpdatedInventories() {
(async () => {
if (!throttledInventoriesToFetch.length) {
return;
}
setInventoriesToFetch([]);
const newInventories = await fetchInventoriesById(
throttledInventoriesToFetch
);
const updated = [...inventories];
newInventories.forEach(inventory => {
const index = inventories.findIndex(i => i.id === inventory.id);
if (index === -1) {
return;
}
updated[index] = inventory;
});
setInventories(updated);
})();
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[throttledInventoriesToFetch, fetchInventoriesById]
);
useEffect(
function processWsMessage() {
if (
!lastMessage?.inventory_id ||
lastMessage.type !== 'inventory_update'
) {
return;
}
const index = inventories.findIndex(
p => p.id === lastMessage.inventory_id
);
if (index === -1) {
return;
}
if (!['pending', 'waiting', 'running'].includes(lastMessage.status)) {
enqueueId(lastMessage.inventory_id);
return;
}
const inventory = inventories[index];
const updatedInventory = {
...inventory,
isSourceSyncRunning: true,
};
setInventories([
...inventories.slice(0, index),
updatedInventory,
...inventories.slice(index + 1),
]);
},
// eslint-disable-next-line react-hooks/exhaustive-deps,
[lastMessage]
);
return inventories;
}

View File

@@ -0,0 +1,129 @@
import React from 'react';
import { act } from 'react-dom/test-utils';
import WS from 'jest-websocket-mock';
import { mountWithContexts } from '../../../../testUtils/enzymeHelpers';
import useWsInventories from './useWsInventories';
/*
Jest mock timers dont 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 <div />;
}
function Test({ inventories, fetch }) {
const syncedJobs = useWsInventories(inventories, fetch);
return <TestInner inventories={syncedJobs} />;
}
describe('useWsInventories 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 inventories list', () => {
const inventories = [{ id: 1 }];
wrapper = mountWithContexts(<Test inventories={inventories} />);
expect(wrapper.find('TestInner').prop('inventories')).toEqual(inventories);
WS.clean();
});
test('should establish websocket connection', async () => {
global.document.cookie = 'csrftoken=abc123';
const mockServer = new WS('wss://localhost/websocket/');
const inventories = [{ id: 1 }];
await act(async () => {
wrapper = await mountWithContexts(<Test inventories={inventories} />);
});
await mockServer.connected;
await expect(mockServer).toReceiveMessage(
JSON.stringify({
xrftoken: 'abc123',
groups: {
inventories: ['status_changed'],
jobs: ['status_changed'],
control: ['limit_reached_1'],
},
})
);
WS.clean();
});
test('should update inventory sync status', async () => {
global.document.cookie = 'csrftoken=abc123';
const mockServer = new WS('wss://localhost/websocket/');
const inventories = [{ id: 1 }];
await act(async () => {
wrapper = await mountWithContexts(<Test inventories={inventories} />);
});
await mockServer.connected;
await expect(mockServer).toReceiveMessage(
JSON.stringify({
xrftoken: 'abc123',
groups: {
inventories: ['status_changed'],
jobs: ['status_changed'],
control: ['limit_reached_1'],
},
})
);
act(() => {
mockServer.send(
JSON.stringify({
inventory_id: 1,
type: 'inventory_update',
status: 'running',
})
);
});
wrapper.update();
expect(
wrapper.find('TestInner').prop('inventories')[0].isSourceSyncRunning
).toEqual(true);
WS.clean();
});
test('should fetch fresh inventory after sync runs', async () => {
global.document.cookie = 'csrftoken=abc123';
const mockServer = new WS('wss://localhost/websocket/');
const inventories = [{ id: 1 }];
const fetch = jest.fn(() => []);
await act(async () => {
wrapper = await mountWithContexts(
<Test inventories={inventories} fetch={fetch} />
);
});
await mockServer.connected;
await act(async () => {
mockServer.send(
JSON.stringify({
inventory_id: 1,
type: 'inventory_update',
status: 'successful',
})
);
});
expect(fetch).toHaveBeenCalledWith([1]);
WS.clean();
});
});

View File

@@ -1,9 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import useWebsocket from '../../../util/useWebsocket';
export default function useWsProjects(initialProjects) {
const [projects, setProjects] = useState(initialProjects);
const [lastMessage, setLastMessage] = useState(null);
const ws = useRef(null);
const lastMessage = useWebsocket({
jobs: ['status_changed'],
control: ['limit_reached_1'],
});
useEffect(() => {
setProjects(initialProjects);
@@ -37,49 +40,5 @@ export default function useWsProjects(initialProjects) {
]);
}, [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;
}

View File

@@ -0,0 +1,49 @@
import { useState, useEffect, useRef } from 'react';
export default function useWebsocket(subscribeGroups) {
const [lastMessage, setLastMessage] = useState(null);
const ws = useRef(null);
useEffect(function setupSocket() {
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: subscribeGroups,
})
);
};
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();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return lastMessage;
}