diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index dd0f4a811c..b3abd8e3bc 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -4,6 +4,7 @@ import Config from './models/Config'; import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; +import Dashboard from './models/Dashboard'; import Groups from './models/Groups'; import Hosts from './models/Hosts'; import InstanceGroups from './models/InstanceGroups'; @@ -42,6 +43,7 @@ const ConfigAPI = new Config(); const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); const CredentialsAPI = new Credentials(); +const DashboardAPI = new Dashboard(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); @@ -81,6 +83,7 @@ export { CredentialInputSourcesAPI, CredentialTypesAPI, CredentialsAPI, + DashboardAPI, GroupsAPI, HostsAPI, InstanceGroupsAPI, diff --git a/awx/ui_next/src/api/models/Dashboard.js b/awx/ui_next/src/api/models/Dashboard.js new file mode 100644 index 0000000000..aa1d86340a --- /dev/null +++ b/awx/ui_next/src/api/models/Dashboard.js @@ -0,0 +1,16 @@ +import Base from '../Base'; + +class Dashboard extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/dashboard/'; + } + + readJobGraph(params) { + return this.http.get(`${this.baseUrl}graphs/jobs/`, { + params, + }); + } +} + +export default Dashboard; diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx index b8e37fb1ad..59f05c2cfb 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.jsx @@ -1,28 +1,244 @@ -import React, { Component, Fragment } from 'react'; +import React, { Fragment, useCallback, useEffect, useState } from 'react'; +import styled from 'styled-components'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { + Card, + CardHeader, + CardHeaderMain, + CardActions, + CardBody, PageSection, PageSectionVariants, + Select, + SelectVariant, + SelectOption, + Tabs, + Tab, + TabTitleText, + Text, + TextContent, + TextVariants, Title, } from '@patternfly/react-core'; -class Dashboard extends Component { - render() { - const { i18n } = this.props; - const { light } = PageSectionVariants; +import useRequest from '../../util/useRequest'; +import { DashboardAPI } from '../../api'; +import JobList from '../../components/JobList'; - return ( - - - - {i18n._(t`Dashboard`)} - - - - - ); +import LineChart from './shared/LineChart'; +import Count from './shared/Count'; +import DashboardTemplateList from './shared/DashboardTemplateList'; + +const Counts = styled.div` + display: grid; + grid-template-columns: repeat(6, 1fr); + grid-gap: var(--pf-global--spacer--lg); + margin-bottom: var(--pf-global--spacer--lg); + + @media (max-width: 900px) { + grid-template-columns: repeat(3, 1fr); + grid-auto-rows: 1fr; } +`; + +const ListPageSection = styled(PageSection)` + padding-top: 0; + padding-bottom: 0; + min-height: 626px; + + & .spacer { + margin-bottom: var(--pf-global--spacer--lg); + } +`; + +function Dashboard({ i18n }) { + const { light } = PageSectionVariants; + + 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 { + result: { jobGraphData, 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: {}, + } + ); + + useEffect(() => { + fetchDashboardGraph(); + }, [fetchDashboardGraph, periodSelection, jobTypeSelection]); + + return ( + + + + {i18n._(t`Dashboard`)} + + + + + + + + + + + + + + + + {i18n._(t`Job Status`)} + + + + + + + + + + + + + +
+ + setActiveTabId(eventKey)} + > + {i18n._(t`Recent Jobs`)}} + /> + {i18n._(t`Recent Templates`)} + } + /> + + {activeTabId === 0 && } + {activeTabId === 1 && } + +
+
+
+ ); } export default withI18n()(Dashboard); diff --git a/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx b/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx index fa8d664c50..339660e90d 100644 --- a/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx +++ b/awx/ui_next/src/screens/Dashboard/Dashboard.test.jsx @@ -1,18 +1,24 @@ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { DashboardAPI } from '../../api'; import Dashboard from './Dashboard'; +jest.mock('../../api'); + describe('', () => { let pageWrapper; - let pageSections; - let title; + let graphRequest; - beforeEach(() => { - pageWrapper = mountWithContexts(); - pageSections = pageWrapper.find('PageSection'); - title = pageWrapper.find('Title'); + beforeEach(async () => { + await act(async () => { + DashboardAPI.read.mockResolvedValue({}); + graphRequest = DashboardAPI.readJobGraph; + graphRequest.mockResolvedValue({}); + pageWrapper = mountWithContexts(); + }); }); afterEach(() => { @@ -21,9 +27,24 @@ describe('', () => { test('initially renders without crashing', () => { expect(pageWrapper.length).toBe(1); - expect(pageSections.length).toBe(2); - expect(title.length).toBe(1); - expect(title.props().size).toBe('2xl'); - expect(pageSections.first().props().variant).toBe('light'); + }); + + test('renders jobs list by default', () => { + expect(pageWrapper.find('JobList').length).toBe(1); + }); + + test('renders template list when the active tab is changed', async () => { + expect(pageWrapper.find('DashboardTemplateList').length).toBe(0); + pageWrapper + .find('button[aria-label="Recent Templates list tab"]') + .simulate('click'); + expect(pageWrapper.find('DashboardTemplateList').length).toBe(1); + }); + + test('renders month-based/all job type chart by default', () => { + expect(graphRequest).toHaveBeenCalledWith({ + job_type: 'all', + period: 'month', + }); }); }); diff --git a/awx/ui_next/src/screens/Dashboard/shared/ChartTooltip.jsx b/awx/ui_next/src/screens/Dashboard/shared/ChartTooltip.jsx new file mode 100644 index 0000000000..d4355037ef --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/ChartTooltip.jsx @@ -0,0 +1,207 @@ +import * as d3 from 'd3'; + +class Tooltip { + constructor(opts) { + this.label = opts.label; + this.svg = opts.svg; + this.colors = opts.colors; + this.draw(); + } + + draw() { + this.toolTipBase = d3.select(`${this.svg} > svg`).append('g'); + this.toolTipBase.attr('id', `svg-chart-Tooltip.base-${this.svg.slice(1)}`); + this.toolTipBase.attr('overflow', 'visible'); + this.toolTipBase.style('opacity', 0); + this.toolTipBase.style('pointer-events', 'none'); + this.toolTipBase.attr('transform', 'translate(100, 100)'); + this.boxWidth = 145; + this.textWidthThreshold = 20; + + this.toolTipPoint = this.toolTipBase + .append('rect') + .attr('transform', 'translate(10, -10) rotate(45)') + .attr('x', 0) + .attr('y', 0) + .attr('height', 20) + .attr('width', 20) + .attr('fill', '#393f44'); + this.boundingBox = this.toolTipBase + .append('rect') + .attr('x', 10) + .attr('y', -41) + .attr('rx', 2) + .attr('height', 82) + .attr('width', this.boxWidth) + .attr('fill', '#393f44'); + this.circleGreen = this.toolTipBase + .append('circle') + .attr('cx', 26) + .attr('cy', 0) + .attr('r', 7) + .attr('stroke', 'white') + .attr('fill', this.colors(1)); + this.circleRed = this.toolTipBase + .append('circle') + .attr('cx', 26) + .attr('cy', 26) + .attr('r', 7) + .attr('stroke', 'white') + .attr('fill', this.colors(0)); + this.successText = this.toolTipBase + .append('text') + .attr('x', 43) + .attr('y', 4) + .attr('font-size', 12) + .attr('fill', 'white') + .text('Successful'); + this.failText = this.toolTipBase + .append('text') + .attr('x', 43) + .attr('y', 28) + .attr('font-size', 12) + .attr('fill', 'white') + .text('Failed'); + this.icon = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('stroke', 'white') + .attr('x', 24) + .attr('y', 30) + .attr('font-size', 12) + .text('!'); + this.jobs = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('x', 137) + .attr('y', -21) + .attr('font-size', 12) + .attr('text-anchor', 'end') + .text('No Jobs'); + this.successful = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('font-size', 12) + .attr('x', 122) + .attr('y', 4) + .text('0'); + this.failed = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('font-size', 12) + .attr('x', 122) + .attr('y', 28) + .text('0'); + this.date = this.toolTipBase + .append('text') + .attr('fill', 'white') + .attr('stroke', 'white') + .attr('x', 20) + .attr('y', -21) + .attr('font-size', 12) + .text('Never'); + } + + handleMouseOver = d => { + let success = 0; + let fail = 0; + let total = 0; + const x = + d3.event.pageX - + d3 + .select(this.svg) + .node() + .getBoundingClientRect().x + + 10; + const y = + d3.event.pageY - + d3 + .select(this.svg) + .node() + .getBoundingClientRect().y - + 10; + const formatTooltipDate = d3.timeFormat('%m/%d'); + if (!d) { + return; + } + + const toolTipWidth = this.toolTipBase.node().getBoundingClientRect().width; + const chartWidth = d3 + .select(`${this.svg}> svg`) + .node() + .getBoundingClientRect().width; + const overflow = 100 - (toolTipWidth / chartWidth) * 100; + const flipped = overflow < (x / chartWidth) * 100; + if (d) { + success = d.RAN || 0; + fail = d.FAIL || 0; + total = d.TOTAL || 0; + this.date.text(formatTooltipDate(d.DATE || null)); + } + + if (d && d.data) { + success = d.data.RAN || 0; + fail = d.data.FAIL || 0; + total = d.data.TOTAL || 0; + this.date.text(formatTooltipDate(d.data.DATE || null)); + } + + this.jobs.text(`${total} ${this.label}`); + this.jobsWidth = this.jobs.node().getComputedTextLength(); + this.failed.text(`${fail}`); + this.successful.text(`${success}`); + this.successTextWidth = this.successful.node().getComputedTextLength(); + this.failTextWidth = this.failed.node().getComputedTextLength(); + + const maxTextPerc = (this.jobsWidth / this.boxWidth) * 100; + const threshold = 40; + const overage = maxTextPerc / threshold; + let adjustedWidth; + if (maxTextPerc > threshold) { + adjustedWidth = this.boxWidth * overage; + } else { + adjustedWidth = this.boxWidth; + } + + this.boundingBox.attr('width', adjustedWidth); + this.toolTipBase.attr('transform', `translate(${x}, ${y})`); + if (flipped) { + this.toolTipPoint.attr('transform', 'translate(-20, -10) rotate(45)'); + this.boundingBox.attr('x', -adjustedWidth - 20); + this.circleGreen.attr('cx', -adjustedWidth); + this.circleRed.attr('cx', -adjustedWidth); + this.icon.attr('x', -adjustedWidth - 2); + this.successText.attr('x', -adjustedWidth + 17); + this.failText.attr('x', -adjustedWidth + 17); + this.successful.attr('x', -this.successTextWidth - 20 - 12); + this.failed.attr('x', -this.failTextWidth - 20 - 12); + this.date.attr('x', -adjustedWidth - 5); + this.jobs.attr('x', -this.jobsWidth / 2 - 14); + } else { + this.toolTipPoint.attr('transform', 'translate(10, -10) rotate(45)'); + this.boundingBox.attr('x', 10); + this.circleGreen.attr('cx', 26); + this.circleRed.attr('cx', 26); + this.icon.attr('x', 24); + this.successText.attr('x', 43); + this.failText.attr('x', 43); + this.successful.attr('x', adjustedWidth - this.successTextWidth); + this.failed.attr('x', adjustedWidth - this.failTextWidth); + this.date.attr('x', 20); + this.jobs.attr('x', adjustedWidth); + } + + this.toolTipBase.style('opacity', 1); + this.toolTipBase.interrupt(); + }; + + handleMouseOut = () => { + this.toolTipBase + .transition() + .delay(15) + .style('opacity', 0) + .style('pointer-events', 'none'); + }; +} + +export default Tooltip; diff --git a/awx/ui_next/src/screens/Dashboard/shared/Count.jsx b/awx/ui_next/src/screens/Dashboard/shared/Count.jsx new file mode 100644 index 0000000000..eb6c692550 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/Count.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Link } from 'react-router-dom'; +import { Card } from '@patternfly/react-core'; + +const CountCard = styled(Card)` + padding: var(--pf-global--spacer--md); + display: flex; + align-items: center; + padding-top: var(--pf-global--spacer--sm); + cursor: pointer; + text-align: center; + color: var(--pf-global--palette--black-1000); + text-decoration: none; + + & h2 { + font-size: var(--pf-global--FontSize--4xl); + color: var(--pf-global--palette--blue-400); + text-decoration: none; + } + + & h2.failed { + color: var(--pf-global--palette--red-200); + } +`; + +const CountLink = styled(Link)` + display: contents; + &:hover { + text-decoration: none; + } +`; + +function Count({ failed, link, data, label }) { + return ( + + +

{data || 0}

+ {label} +
+
+ ); +} + +export default Count; diff --git a/awx/ui_next/src/screens/Dashboard/shared/Count.test.jsx b/awx/ui_next/src/screens/Dashboard/shared/Count.test.jsx new file mode 100644 index 0000000000..58b67ff9c3 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/Count.test.jsx @@ -0,0 +1,28 @@ +import React from 'react'; + +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import Count from './Count'; + +describe('', () => { + let pageWrapper; + + afterEach(() => { + pageWrapper.unmount(); + }); + + test('initially renders without crashing', () => { + pageWrapper = mountWithContexts(); + expect(pageWrapper.length).toBe(1); + }); + + test('renders non-failed version of count without prop', () => { + pageWrapper = mountWithContexts(); + expect(pageWrapper.find('h2').hasClass('failed')).toBe(false); + }); + + test('renders failed version of count with appropriate prop', () => { + pageWrapper = mountWithContexts(); + expect(pageWrapper.find('h2').hasClass('failed')).toBe(true); + }); +}); diff --git a/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx b/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx new file mode 100644 index 0000000000..f72856bd45 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/LineChart.jsx @@ -0,0 +1,283 @@ +import React, { useEffect, useCallback } from 'react'; +import { string, number, shape, arrayOf } from 'prop-types'; +import * as d3 from 'd3'; +import { t } from '@lingui/macro'; +import { withI18n } from '@lingui/react'; +import { PageContextConsumer } from '@patternfly/react-core'; + +import ChartTooltip from './ChartTooltip'; + +function LineChart({ id, data, height, i18n, pageContext }) { + const { isNavOpen } = pageContext; + + // Methods + const draw = useCallback(() => { + const margin = { top: 15, right: 15, bottom: 35, left: 70 }; + + const getWidth = () => { + let width; + // This is in an a try/catch due to an error from jest. + // Even though the d3.select returns a valid selector with + // style function, it says it is null in the test + try { + width = + parseInt(d3.select(`#${id}`).style('width'), 10) - + margin.left - + margin.right || 700; + } catch (error) { + width = 700; + } + return width; + }; + + // Clear our chart container element first + d3.selectAll(`#${id} > *`).remove(); + const width = getWidth(); + + function transition(path) { + path + .transition() + .duration(1000) + .attrTween('stroke-dasharray', tweenDash); + } + + function tweenDash(...params) { + const l = params[2][params[1]].getTotalLength(); + const i = d3.interpolateString(`0,${l}`, `${l},${l}`); + return val => i(val); + } + + const x = d3.scaleTime().rangeRound([0, width]); + const y = d3.scaleLinear().range([height, 0]); + + // [success, fail, total] + const colors = d3.scaleOrdinal(['#6EC664', '#A30000', '#06C']); + const svg = d3 + .select(`#${id}`) + .append('svg') + .attr('width', width + margin.left + margin.right) + .attr('height', height + margin.top + margin.bottom) + .attr('z', 100) + .append('g') + .attr('transform', `translate(${margin.left}, ${margin.top})`); + // Tooltip + const tooltip = new ChartTooltip({ + svg: `#${id}`, + colors, + label: i18n._(t`Jobs`), + }); + const parseTime = d3.timeParse('%Y-%m-%d'); + + const formattedData = data.reduce( + (formatted, { created, successful, failed }) => { + const DATE = parseTime(created) || new Date(); + const RAN = +successful || 0; + const FAIL = +failed || 0; + const TOTAL = +successful + failed || 0; + return formatted.concat({ DATE, RAN, FAIL, TOTAL }); + }, + [] + ); + // Scale the range of the data + const largestY = formattedData.reduce((a_max, b) => { + const b_max = Math.max(b.RAN > b.FAIL ? b.RAN : b.FAIL); + return a_max > b_max ? a_max : b_max; + }, 0); + x.domain(d3.extent(formattedData, d => d.DATE)); + y.domain([ + 0, + largestY > 4 ? largestY + Math.max(largestY / 10, 1) : 5, + ]).nice(); + + const successLine = d3 + .line() + .curve(d3.curveMonotoneX) + .x(d => x(d.DATE)) + .y(d => y(d.RAN)); + + const failLine = d3 + .line() + .defined(d => typeof d.FAIL === 'number') + .curve(d3.curveMonotoneX) + .x(d => x(d.DATE)) + .y(d => y(d.FAIL)); + // Add the Y Axis + svg + .append('g') + .attr('class', 'y-axis') + .call( + d3 + .axisLeft(y) + .ticks( + largestY > 3 + ? Math.min(largestY + Math.max(largestY / 10, 1), 10) + : 5 + ) + .tickSize(-width) + .tickFormat(d3.format('d')) + ) + .selectAll('line') + .attr('stroke', '#d7d7d7'); + svg.selectAll('.y-axis .tick text').attr('x', -5); + + // text label for the y axis + svg + .append('text') + .attr('transform', 'rotate(-90)') + .attr('y', 0 - margin.left) + .attr('x', 0 - height / 2) + .attr('dy', '1em') + .style('text-anchor', 'middle') + .text('Job Runs'); + + // Add the X Axis + let ticks; + const maxTicks = Math.round( + formattedData.length / (formattedData.length / 2) + ); + ticks = formattedData.map(d => d.DATE); + if (formattedData.length === 31) { + ticks = formattedData + .map((d, i) => (i % maxTicks === 0 ? d.DATE : undefined)) + .filter(item => item); + } + + svg.select('.domain').attr('stroke', '#d7d7d7'); + + svg + .append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0, ${height})`) + .call( + d3 + .axisBottom(x) + .tickValues(ticks) + .tickSize(-height) + .tickFormat(d3.timeFormat('%-m/%-d')) // "1/19" + ) // "Jan-01" + .selectAll('line') + .attr('stroke', '#d7d7d7'); + + svg.selectAll('.x-axis .tick text').attr('y', 10); + + // text label for the x axis + svg + .append('text') + .attr( + 'transform', + `translate(${width / 2} , ${height + margin.top + 20})` + ) + .style('text-anchor', 'middle') + .text('Date'); + const vertical = svg + .append('path') + .attr('class', 'mouse-line') + .style('stroke', 'black') + .style('stroke-width', '3px') + .style('stroke-dasharray', '3, 3') + .style('opacity', '0'); + + const handleMouseOver = d => { + tooltip.handleMouseOver(d); + // show vertical line + vertical.transition().style('opacity', '1'); + }; + + const handleMouseMove = function mouseMove(...params) { + const intersectX = params[2][params[1]].cx.baseVal.value; + vertical.attr('d', () => `M${intersectX},${height} ${intersectX},${0}`); + }; + + const handleMouseOut = () => { + // hide tooltip + tooltip.handleMouseOut(); + // hide vertical line + 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); + 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)) + .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)) + .on('mouseover', handleMouseOver) + .on('mousemove', handleMouseMove) + .on('mouseout', handleMouseOut); + }, [data, height, i18n, id]); + + useEffect(() => { + draw(); + }, [draw, isNavOpen]); + + useEffect(() => { + function handleResize() { + draw(); + } + + window.addEventListener('resize', handleResize); + + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, [draw]); + + return
; +} + +LineChart.propTypes = { + id: string.isRequired, + data: arrayOf(shape({})).isRequired, + height: number.isRequired, +}; + +const withPageContext = Component => { + return function contextComponent(props) { + return ( + + {pageContext => } + + ); + }; +}; + +export default withI18n()(withPageContext(LineChart)); diff --git a/awx/ui_next/src/screens/Dashboard/shared/data.job_template.json b/awx/ui_next/src/screens/Dashboard/shared/data.job_template.json new file mode 100644 index 0000000000..e284107691 --- /dev/null +++ b/awx/ui_next/src/screens/Dashboard/shared/data.job_template.json @@ -0,0 +1,181 @@ +{ + "id": 7, + "type": "job_template", + "url": "/api/v2/job_templates/7/", + "related": { + "named_url": "/api/v2/job_templates/Mike's JT/", + "created_by": "/api/v2/users/1/", + "modified_by": "/api/v2/users/1/", + "labels": "/api/v2/job_templates/7/labels/", + "inventory": "/api/v2/inventories/1/", + "project": "/api/v2/projects/6/", + "credentials": "/api/v2/job_templates/7/credentials/", + "last_job": "/api/v2/jobs/12/", + "jobs": "/api/v2/job_templates/7/jobs/", + "schedules": "/api/v2/job_templates/7/schedules/", + "activity_stream": "/api/v2/job_templates/7/activity_stream/", + "launch": "/api/v2/job_templates/7/launch/", + "notification_templates_started": "/api/v2/job_templates/7/notification_templates_started/", + "notification_templates_success": "/api/v2/job_templates/7/notification_templates_success/", + "notification_templates_error": "/api/v2/job_templates/7/notification_templates_error/", + "access_list": "/api/v2/job_templates/7/access_list/", + "survey_spec": "/api/v2/job_templates/7/survey_spec/", + "object_roles": "/api/v2/job_templates/7/object_roles/", + "instance_groups": "/api/v2/job_templates/7/instance_groups/", + "slice_workflow_jobs": "/api/v2/job_templates/7/slice_workflow_jobs/", + "copy": "/api/v2/job_templates/7/copy/", + "webhook_receiver": "/api/v2/job_templates/7/github/", + "webhook_key": "/api/v2/job_templates/7/webhook_key/" + }, + "summary_fields": { + "inventory": { + "id": 1, + "name": "Mike's Inventory", + "description": "", + "has_active_failures": false, + "total_hosts": 1, + "hosts_with_active_failures": 0, + "total_groups": 0, + "groups_with_active_failures": 0, + "has_inventory_sources": false, + "total_inventory_sources": 0, + "inventory_sources_with_failures": 0, + "organization_id": 1, + "kind": "" + }, + "project": { + "id": 6, + "name": "Mike's Project", + "description": "", + "status": "successful", + "scm_type": "git" + }, + "last_job": { + "id": 12, + "name": "Mike's JT", + "description": "", + "finished": "2019-10-01T14:34:35.142483Z", + "status": "successful", + "failed": false + }, + "last_update": { + "id": 12, + "name": "Mike's JT", + "description": "", + "status": "successful", + "failed": false + }, + "created_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "modified_by": { + "id": 1, + "username": "admin", + "first_name": "", + "last_name": "" + }, + "object_roles": { + "admin_role": { + "description": "Can manage all aspects of the job template", + "name": "Admin", + "id": 24 + }, + "execute_role": { + "description": "May run the job template", + "name": "Execute", + "id": 25 + }, + "read_role": { + "description": "May view settings for the job template", + "name": "Read", + "id": 26 + } + }, + "user_capabilities": { + "edit": true, + "delete": true, + "start": true, + "schedule": true, + "copy": true + }, + "labels": { + "count": 1, + "results": [{ + "id": 91, + "name": "L_91o2" + }] + }, + "survey": { + "title": "", + "description": "" + }, + "recent_jobs": [{ + "id": 12, + "status": "successful", + "finished": "2019-10-01T14:34:35.142483Z", + "type": "job" + }], + "credentials": [{ + "id": 1, + "kind": "ssh", + "name": "Credential 1" + }, + { + "id": 2, + "kind": "awx", + "name": "Credential 2" + } + ], + "webhook_credential": { + "id": "1", + "name": "Webhook Credential" + + } + }, + "created": "2019-09-30T16:18:34.564820Z", + "modified": "2019-10-01T14:47:31.818431Z", + "name": "Mike's JT", + "description": "", + "job_type": "run", + "inventory": 1, + "project": 6, + "playbook": "ping.yml", + "scm_branch": "Foo branch", + "forks": 0, + "limit": "", + "verbosity": 0, + "extra_vars": "", + "job_tags": "T_100,T_200", + "force_handlers": false, + "skip_tags": "S_100,S_200", + "start_at_task": "", + "timeout": 0, + "use_fact_cache": true, + "last_job_run": "2019-10-01T14:34:35.142483Z", + "last_job_failed": false, + "next_job_run": null, + "status": "successful", + "host_config_key": "", + "ask_scm_branch_on_launch": false, + "ask_diff_mode_on_launch": false, + "ask_variables_on_launch": false, + "ask_limit_on_launch": false, + "ask_tags_on_launch": false, + "ask_skip_tags_on_launch": false, + "ask_job_type_on_launch": false, + "ask_verbosity_on_launch": false, + "ask_inventory_on_launch": false, + "ask_credential_on_launch": false, + "survey_enabled": true, + "become_enabled": false, + "diff_mode": false, + "allow_simultaneous": false, + "custom_virtualenv": null, + "job_slice_count": 1, + "webhook_credential": 1, + "webhook_key": "asertdyuhjkhgfd234567kjgfds", + "webhook_service": "github" +}