mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
Merge pull request #10349 from AlexSCorey/8401-MoreFiltersonDashboard
Adds more filters to dashboard chart SUMMARY Resolves #8401 and #10356 ISSUE TYPE Bugfix Pull Request COMPONENT NAME UI AWX VERSION ADDITIONAL INFORMATION Reviewed-by: Jake McDermott <yo@jakemcdermott.me> Reviewed-by: Alex Corey <Alex.swansboro@gmail.com> Reviewed-by: Tiago Góes <tiago.goes2009@gmail.com>
This commit is contained in:
commit
1fd6ba0bfc
@ -4,13 +4,7 @@ import styled from 'styled-components';
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardActions,
|
||||
CardBody,
|
||||
PageSection,
|
||||
Select,
|
||||
SelectVariant,
|
||||
SelectOption,
|
||||
Tabs,
|
||||
Tab,
|
||||
TabTitleText,
|
||||
@ -21,9 +15,9 @@ import { DashboardAPI } from '../../api';
|
||||
import ScreenHeader from '../../components/ScreenHeader';
|
||||
import JobList from '../../components/JobList';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import LineChart from './shared/LineChart';
|
||||
import Count from './shared/Count';
|
||||
import TemplateList from '../../components/TemplateList';
|
||||
import DashboardGraph from './DashboardGraph';
|
||||
|
||||
const Counts = styled.div`
|
||||
display: grid;
|
||||
@ -45,67 +39,25 @@ const MainPageSection = styled(PageSection)`
|
||||
}
|
||||
`;
|
||||
|
||||
const GraphCardHeader = styled(CardHeader)`
|
||||
margin-top: var(--pf-global--spacer--lg);
|
||||
`;
|
||||
|
||||
const GraphCardActions = styled(CardActions)`
|
||||
margin-left: initial;
|
||||
padding-left: 0;
|
||||
`;
|
||||
|
||||
function Dashboard() {
|
||||
const [isPeriodDropdownOpen, setIsPeriodDropdownOpen] = useState(false);
|
||||
const [isJobTypeDropdownOpen, setIsJobTypeDropdownOpen] = useState(false);
|
||||
const [periodSelection, setPeriodSelection] = useState('month');
|
||||
const [jobTypeSelection, setJobTypeSelection] = useState('all');
|
||||
const [activeTabId, setActiveTabId] = useState(0);
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
result: { jobGraphData, countData },
|
||||
result: countData,
|
||||
request: fetchDashboardGraph,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const [{ data }, { data: dataFromCount }] = await Promise.all([
|
||||
DashboardAPI.readJobGraph({
|
||||
period: periodSelection,
|
||||
job_type: jobTypeSelection,
|
||||
}),
|
||||
DashboardAPI.read(),
|
||||
]);
|
||||
const newData = {};
|
||||
data.jobs.successful.forEach(([dateSecs, count]) => {
|
||||
if (!newData[dateSecs]) {
|
||||
newData[dateSecs] = {};
|
||||
}
|
||||
newData[dateSecs].successful = count;
|
||||
});
|
||||
data.jobs.failed.forEach(([dateSecs, count]) => {
|
||||
if (!newData[dateSecs]) {
|
||||
newData[dateSecs] = {};
|
||||
}
|
||||
newData[dateSecs].failed = count;
|
||||
});
|
||||
const jobData = Object.keys(newData).map(dateSecs => {
|
||||
const [created] = new Date(dateSecs * 1000).toISOString().split('T');
|
||||
newData[dateSecs].created = created;
|
||||
return newData[dateSecs];
|
||||
});
|
||||
return {
|
||||
jobGraphData: jobData,
|
||||
countData: dataFromCount,
|
||||
};
|
||||
}, [periodSelection, jobTypeSelection]),
|
||||
{
|
||||
jobGraphData: [],
|
||||
countData: {},
|
||||
}
|
||||
const { data: dataFromCount } = await DashboardAPI.read();
|
||||
|
||||
return dataFromCount;
|
||||
}, []),
|
||||
{}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardGraph();
|
||||
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
|
||||
}, [fetchDashboardGraph]);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
@ -171,67 +123,7 @@ function Dashboard() {
|
||||
eventKey={0}
|
||||
title={<TabTitleText>{t`Job status`}</TabTitleText>}
|
||||
>
|
||||
<Fragment>
|
||||
<GraphCardHeader>
|
||||
<GraphCardActions>
|
||||
<Select
|
||||
variant={SelectVariant.single}
|
||||
placeholderText={t`Select period`}
|
||||
aria-label={t`Select period`}
|
||||
typeAheadAriaLabel={t`Select period`}
|
||||
className="periodSelect"
|
||||
onToggle={setIsPeriodDropdownOpen}
|
||||
onSelect={(event, selection) =>
|
||||
setPeriodSelection(selection)
|
||||
}
|
||||
selections={periodSelection}
|
||||
isOpen={isPeriodDropdownOpen}
|
||||
>
|
||||
<SelectOption key="month" value="month">
|
||||
{t`Past month`}
|
||||
</SelectOption>
|
||||
<SelectOption key="two_weeks" value="two_weeks">
|
||||
{t`Past two weeks`}
|
||||
</SelectOption>
|
||||
<SelectOption key="week" value="week">
|
||||
{t`Past week`}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<Select
|
||||
variant={SelectVariant.single}
|
||||
placeholderText={t`Select job type`}
|
||||
aria-label={t`Select job type`}
|
||||
className="jobTypeSelect"
|
||||
onToggle={setIsJobTypeDropdownOpen}
|
||||
onSelect={(event, selection) =>
|
||||
setJobTypeSelection(selection)
|
||||
}
|
||||
selections={jobTypeSelection}
|
||||
isOpen={isJobTypeDropdownOpen}
|
||||
>
|
||||
<SelectOption key="all" value="all">
|
||||
{t`All job types`}
|
||||
</SelectOption>
|
||||
<SelectOption key="inv_sync" value="inv_sync">
|
||||
{t`Inventory sync`}
|
||||
</SelectOption>
|
||||
<SelectOption key="scm_update" value="scm_update">
|
||||
{t`SCM update`}
|
||||
</SelectOption>
|
||||
<SelectOption key="playbook_run" value="playbook_run">
|
||||
{t`Playbook run`}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</GraphCardActions>
|
||||
</GraphCardHeader>
|
||||
<CardBody>
|
||||
<LineChart
|
||||
height={390}
|
||||
id="d3-line-chart-root"
|
||||
data={jobGraphData}
|
||||
/>
|
||||
</CardBody>
|
||||
</Fragment>
|
||||
<DashboardGraph />
|
||||
</Tab>
|
||||
<Tab
|
||||
aria-label={t`Recent Jobs list tab`}
|
||||
|
||||
@ -46,11 +46,4 @@ describe('<Dashboard />', () => {
|
||||
pageWrapper.update();
|
||||
expect(pageWrapper.find('TemplateList').length).toBe(1);
|
||||
});
|
||||
|
||||
test('renders month-based/all job type chart by default', () => {
|
||||
expect(graphRequest).toHaveBeenCalledWith({
|
||||
job_type: 'all',
|
||||
period: 'month',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
182
awx/ui_next/src/screens/Dashboard/DashboardGraph.jsx
Normal file
182
awx/ui_next/src/screens/Dashboard/DashboardGraph.jsx
Normal file
@ -0,0 +1,182 @@
|
||||
import React, { Fragment, useCallback, useEffect, useState } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardActions,
|
||||
CardBody,
|
||||
PageSection,
|
||||
Select,
|
||||
SelectVariant,
|
||||
SelectOption,
|
||||
} from '@patternfly/react-core';
|
||||
|
||||
import useRequest from '../../util/useRequest';
|
||||
import { DashboardAPI } from '../../api';
|
||||
import ContentLoading from '../../components/ContentLoading';
|
||||
import LineChart from './shared/LineChart';
|
||||
|
||||
const StatusSelect = styled(Select)`
|
||||
&& {
|
||||
--pf-c-select__toggle--MinWidth: 165px;
|
||||
}
|
||||
`;
|
||||
const GraphCardHeader = styled(CardHeader)`
|
||||
margin-top: var(--pf-global--spacer--lg);
|
||||
`;
|
||||
|
||||
const GraphCardActions = styled(CardActions)`
|
||||
margin-left: initial;
|
||||
padding-left: 0;
|
||||
`;
|
||||
|
||||
function DashboardGraph() {
|
||||
const [isPeriodDropdownOpen, setIsPeriodDropdownOpen] = useState(false);
|
||||
const [isJobTypeDropdownOpen, setIsJobTypeDropdownOpen] = useState(false);
|
||||
const [isJobStatusDropdownOpen, setIsJobStatusDropdownOpen] = useState(false);
|
||||
const [periodSelection, setPeriodSelection] = useState('month');
|
||||
const [jobTypeSelection, setJobTypeSelection] = useState('all');
|
||||
const [jobStatusSelection, setJobStatusSelection] = useState('all');
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
result: jobGraphData,
|
||||
request: fetchDashboardGraph,
|
||||
} = useRequest(
|
||||
useCallback(async () => {
|
||||
const { data } = await DashboardAPI.readJobGraph({
|
||||
period: periodSelection,
|
||||
job_type: jobTypeSelection,
|
||||
});
|
||||
const newData = {};
|
||||
data.jobs.successful.forEach(([dateSecs, count]) => {
|
||||
if (!newData[dateSecs]) {
|
||||
newData[dateSecs] = {};
|
||||
}
|
||||
newData[dateSecs].successful = count;
|
||||
});
|
||||
data.jobs.failed.forEach(([dateSecs, count]) => {
|
||||
if (!newData[dateSecs]) {
|
||||
newData[dateSecs] = {};
|
||||
}
|
||||
newData[dateSecs].failed = count;
|
||||
});
|
||||
const jobData = Object.keys(newData).map(dateSecs => {
|
||||
const [created] = new Date(dateSecs * 1000).toISOString().split('T');
|
||||
newData[dateSecs].created = created;
|
||||
return newData[dateSecs];
|
||||
});
|
||||
return jobData;
|
||||
}, [periodSelection, jobTypeSelection]),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardGraph();
|
||||
}, [fetchDashboardGraph, periodSelection, jobTypeSelection]);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageSection>
|
||||
<Card>
|
||||
<ContentLoading />
|
||||
</Card>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<GraphCardHeader>
|
||||
<GraphCardActions>
|
||||
<Select
|
||||
variant={SelectVariant.single}
|
||||
placeholderText={t`Select period`}
|
||||
aria-label={t`Select period`}
|
||||
typeAheadAriaLabel={t`Select period`}
|
||||
className="periodSelect"
|
||||
onToggle={setIsPeriodDropdownOpen}
|
||||
onSelect={(event, selection) => {
|
||||
setIsPeriodDropdownOpen(false);
|
||||
setPeriodSelection(selection);
|
||||
}}
|
||||
selections={periodSelection}
|
||||
isOpen={isPeriodDropdownOpen}
|
||||
>
|
||||
<SelectOption key="month" value="month">
|
||||
{t`Past month`}
|
||||
</SelectOption>
|
||||
<SelectOption key="two_weeks" value="two_weeks">
|
||||
{t`Past two weeks`}
|
||||
</SelectOption>
|
||||
<SelectOption key="week" value="week">
|
||||
{t`Past week`}
|
||||
</SelectOption>
|
||||
<SelectOption key="day" value="day">
|
||||
{t`Past 24 hours`}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<Select
|
||||
variant={SelectVariant.single}
|
||||
placeholderText={t`Select job type`}
|
||||
aria-label={t`Select job type`}
|
||||
className="jobTypeSelect"
|
||||
onToggle={setIsJobTypeDropdownOpen}
|
||||
onSelect={(event, selection) => {
|
||||
setIsJobTypeDropdownOpen(false);
|
||||
setJobTypeSelection(selection);
|
||||
}}
|
||||
selections={jobTypeSelection}
|
||||
isOpen={isJobTypeDropdownOpen}
|
||||
>
|
||||
<SelectOption key="all" value="all">
|
||||
{t`All job types`}
|
||||
</SelectOption>
|
||||
<SelectOption key="inv_sync" value="inv_sync">
|
||||
{t`Inventory sync`}
|
||||
</SelectOption>
|
||||
<SelectOption key="scm_update" value="scm_update">
|
||||
{t`SCM update`}
|
||||
</SelectOption>
|
||||
<SelectOption key="playbook_run" value="playbook_run">
|
||||
{t`Playbook run`}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
<StatusSelect
|
||||
variant={SelectVariant.single}
|
||||
placeholderText={t`Select status`}
|
||||
aria-label={t`Select status`}
|
||||
className="jobStatusSelect"
|
||||
onToggle={setIsJobStatusDropdownOpen}
|
||||
onSelect={(event, selection) => {
|
||||
setIsJobStatusDropdownOpen(false);
|
||||
setJobStatusSelection(selection);
|
||||
}}
|
||||
selections={jobStatusSelection}
|
||||
isOpen={isJobStatusDropdownOpen}
|
||||
>
|
||||
<SelectOption key="all" value="all">{t`All jobs`}</SelectOption>
|
||||
<SelectOption
|
||||
key="successful"
|
||||
value="successful"
|
||||
>{t`Successful jobs`}</SelectOption>
|
||||
<SelectOption
|
||||
key="failed"
|
||||
value="failed"
|
||||
>{t`Failed jobs`}</SelectOption>
|
||||
</StatusSelect>
|
||||
</GraphCardActions>
|
||||
</GraphCardHeader>
|
||||
<CardBody>
|
||||
<LineChart
|
||||
jobStatus={jobStatusSelection}
|
||||
height={390}
|
||||
id="d3-line-chart-root"
|
||||
data={jobGraphData}
|
||||
/>
|
||||
</CardBody>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
export default DashboardGraph;
|
||||
67
awx/ui_next/src/screens/Dashboard/DashboardGraph.test.jsx
Normal file
67
awx/ui_next/src/screens/Dashboard/DashboardGraph.test.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
import { mountWithContexts } from '../../../testUtils/enzymeHelpers';
|
||||
|
||||
import { DashboardAPI } from '../../api';
|
||||
import DashboardGraph from './DashboardGraph';
|
||||
|
||||
jest.mock('../../api');
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
}));
|
||||
|
||||
describe('<DashboardGraph/>', () => {
|
||||
let pageWrapper;
|
||||
let graphRequest;
|
||||
|
||||
beforeEach(async () => {
|
||||
await act(async () => {
|
||||
DashboardAPI.read.mockResolvedValue({});
|
||||
graphRequest = DashboardAPI.readJobGraph;
|
||||
graphRequest.mockResolvedValue({});
|
||||
pageWrapper = mountWithContexts(<DashboardGraph />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
pageWrapper.unmount();
|
||||
});
|
||||
test('renders month-based/all job type chart by default', () => {
|
||||
expect(graphRequest).toHaveBeenCalledWith({
|
||||
job_type: 'all',
|
||||
period: 'month',
|
||||
});
|
||||
});
|
||||
|
||||
test('should render all three line chart filters with correct number of options', async () => {
|
||||
expect(pageWrapper.find('Select[variant="single"]')).toHaveLength(3);
|
||||
await act(async () => {
|
||||
pageWrapper
|
||||
.find('Select[placeholderText="Select job type"]')
|
||||
.prop('onToggle')(true);
|
||||
});
|
||||
pageWrapper.update();
|
||||
expect(pageWrapper.find('SelectOption')).toHaveLength(4);
|
||||
await act(async () => {
|
||||
pageWrapper
|
||||
.find('Select[placeholderText="Select job type"]')
|
||||
.prop('onToggle')(false);
|
||||
pageWrapper
|
||||
.find('Select[placeholderText="Select period"]')
|
||||
.prop('onToggle')(true);
|
||||
});
|
||||
pageWrapper.update();
|
||||
expect(pageWrapper.find('SelectOption')).toHaveLength(4);
|
||||
await act(async () => {
|
||||
pageWrapper
|
||||
.find('Select[placeholderText="Select period"]')
|
||||
.prop('onToggle')(false);
|
||||
pageWrapper
|
||||
.find('Select[placeholderText="Select status"]')
|
||||
.prop('onToggle')(true);
|
||||
});
|
||||
pageWrapper.update();
|
||||
expect(pageWrapper.find('SelectOption')).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
@ -7,7 +7,7 @@ import { PageContextConsumer } from '@patternfly/react-core';
|
||||
|
||||
import ChartTooltip from './ChartTooltip';
|
||||
|
||||
function LineChart({ id, data, height, pageContext }) {
|
||||
function LineChart({ id, data, height, pageContext, jobStatus }) {
|
||||
const { isNavOpen } = pageContext;
|
||||
|
||||
// Methods
|
||||
@ -197,61 +197,68 @@ function LineChart({ id, data, height, pageContext }) {
|
||||
vertical.transition().style('opacity', 0);
|
||||
};
|
||||
|
||||
// Add the successLine path.
|
||||
svg
|
||||
.append('path')
|
||||
.data([formattedData])
|
||||
.attr('class', 'line')
|
||||
.style('fill', 'none')
|
||||
.style('stroke', () => colors(1))
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', successLine)
|
||||
.call(transition);
|
||||
|
||||
// Add the failLine path.
|
||||
svg
|
||||
.append('path')
|
||||
.data([formattedData])
|
||||
.attr('class', 'line')
|
||||
.style('fill', 'none')
|
||||
.style('stroke', () => colors(0))
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', failLine)
|
||||
.call(transition);
|
||||
|
||||
const dateFormat = d3.timeFormat('%-m-%-d');
|
||||
|
||||
// create our successLine circles
|
||||
svg
|
||||
.selectAll('dot')
|
||||
.data(formattedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 3)
|
||||
.style('stroke', () => colors(1))
|
||||
.style('fill', () => colors(1))
|
||||
.attr('cx', d => x(d.DATE))
|
||||
.attr('cy', d => y(d.RAN))
|
||||
.attr('id', d => `success-dot-${dateFormat(d.DATE)}`)
|
||||
.on('mouseover', handleMouseOver)
|
||||
.on('mousemove', handleMouseMove)
|
||||
.on('mouseout', handleMouseOut);
|
||||
// create our failLine circles
|
||||
svg
|
||||
.selectAll('dot')
|
||||
.data(formattedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 3)
|
||||
.style('stroke', () => colors(0))
|
||||
.style('fill', () => colors(0))
|
||||
.attr('cx', d => x(d.DATE))
|
||||
.attr('cy', d => y(d.FAIL))
|
||||
.attr('id', d => `fail-dot-${dateFormat(d.DATE)}`)
|
||||
.on('mouseover', handleMouseOver)
|
||||
.on('mousemove', handleMouseMove)
|
||||
.on('mouseout', handleMouseOut);
|
||||
}, [data, height, id]);
|
||||
if (jobStatus !== 'failed') {
|
||||
// Add the success line path.
|
||||
svg
|
||||
.append('path')
|
||||
.data([formattedData])
|
||||
.attr('class', 'line')
|
||||
.style('fill', 'none')
|
||||
.style('stroke', () => colors(1))
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', successLine)
|
||||
.call(transition);
|
||||
|
||||
// create our success line circles
|
||||
|
||||
svg
|
||||
.selectAll('dot')
|
||||
.data(formattedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 3)
|
||||
.style('stroke', () => colors(1))
|
||||
.style('fill', () => colors(1))
|
||||
.attr('cx', d => x(d.DATE))
|
||||
.attr('cy', d => y(d.RAN))
|
||||
.attr('id', d => `success-dot-${dateFormat(d.DATE)}`)
|
||||
.on('mouseover', handleMouseOver)
|
||||
.on('mousemove', handleMouseMove)
|
||||
.on('mouseout', handleMouseOut);
|
||||
}
|
||||
|
||||
if (jobStatus !== 'successful') {
|
||||
// Add the failed line path.
|
||||
svg
|
||||
.append('path')
|
||||
.data([formattedData])
|
||||
.attr('class', 'line')
|
||||
.style('fill', 'none')
|
||||
.style('stroke', () => colors(0))
|
||||
.attr('stroke-width', 2)
|
||||
.attr('d', failLine)
|
||||
.call(transition);
|
||||
|
||||
// create our failed line circles
|
||||
|
||||
svg
|
||||
.selectAll('dot')
|
||||
.data(formattedData)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('r', 3)
|
||||
.style('stroke', () => colors(0))
|
||||
.style('fill', () => colors(0))
|
||||
.attr('cx', d => x(d.DATE))
|
||||
.attr('cy', d => y(d.FAIL))
|
||||
.attr('id', d => `fail-dot-${dateFormat(d.DATE)}`)
|
||||
.on('mouseover', handleMouseOver)
|
||||
.on('mousemove', handleMouseMove)
|
||||
.on('mouseout', handleMouseOut);
|
||||
}
|
||||
}, [data, height, id, jobStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
draw();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user