diff --git a/.gitignore b/.gitignore index 156567c1..a31c3081 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,6 @@ test/* .vscode/ robot_test/logs/ *.env -*.pyc \ No newline at end of file +*.pyc + +xeno diff --git a/frontend/.prettierrc b/frontend/.prettierrc index 8421bbcf..a262ba54 100644 --- a/frontend/.prettierrc +++ b/frontend/.prettierrc @@ -1,4 +1,5 @@ { "singleQuote": true, - "semi": true + "semi": true, + "trailingComma": "es5" } diff --git a/frontend/package.json b/frontend/package.json index 67fdbd38..3bb83c3d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,17 +4,22 @@ "private": true, "dependencies": { "@emotion/core": "^10.0.21", + "apexcharts": "^3.19.2", "i18next": "^19.4.4", "normalize.css": "^8.0.1", "prop-types": "^15.7.2", "ramda": "^0.27.0", - "react": "^16.10.2", + "react": "^16.13.1", + "react-apexcharts": "^1.3.7", "react-dom": "^16.10.2", "react-fontawesome": "^1.7.1", "react-i18next": "^11.4.0", "react-router": "^5.1.2", "react-router-dom": "^5.1.2", - "react-scripts": "^3.3.0" + "react-scripts": "^3.3.0", + "react-vega": "^7.3.0", + "vega": "^5.13.0", + "vega-lite": "^4.13.0" }, "devDependencies": { "babel-eslint": "^10.0.3", diff --git a/frontend/src/components/Card.js b/frontend/src/components/Card.js index 921ed7b7..08107f70 100644 --- a/frontend/src/components/Card.js +++ b/frontend/src/components/Card.js @@ -9,7 +9,7 @@ const Card = ({ team, numberOfSeries }) => { let history = useHistory(); const Mongolia = css` - background-color: var(--powder-white); + background-color: var(--nero-white); box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 4px, rgba(0, 0, 0, 0.23) 0px 3px 4px; margin: 10px; diff --git a/frontend/src/components/SelectedTeam.js b/frontend/src/components/SelectedTeam.js index 185c085e..8f1bd75b 100644 --- a/frontend/src/components/SelectedTeam.js +++ b/frontend/src/components/SelectedTeam.js @@ -14,7 +14,7 @@ const SelectedTeam = ({ selectedTeam }) => { const [t] = useTranslation(['team']); const cardStyles = css` - background-color: var(--powder-white); + background-color: var(--nero-white); box-shadow: rgba(0, 0, 0, 0.16) 0px 3px 4px, rgba(0, 0, 0, 0.23) 0px 3px 4px; margin: 10px; @@ -35,7 +35,7 @@ const SelectedTeam = ({ selectedTeam }) => { margin-top: 0; } .cardInfoContainer:hover { - background-color: var(--mithril-grey); + background-color: var(--hermanni-grey); } .cardValue { @@ -55,7 +55,7 @@ const SelectedTeam = ({ selectedTeam }) => { const flexContainer = { display: 'flex', flexWrap: 'wrap', - paddingTop: '20px' + paddingTop: '20px', }; const TeamCard = ({ data }) => { @@ -66,7 +66,7 @@ const SelectedTeam = ({ selectedTeam }) => { last_build, last_build_id, last_started, - last_status + last_status, } = data; const LastStarted = last_started.slice(0, 16); @@ -143,8 +143,8 @@ SelectedTeam.propTypes = { all_builds: PropTypes.object, name: PropTypes.string, series: PropTypes.array, - series_count: PropTypes.number - }) + series_count: PropTypes.number, + }), }; export default SelectedTeam; diff --git a/frontend/src/components/graphs/StatusCount.js b/frontend/src/components/graphs/StatusCount.js new file mode 100644 index 00000000..aa7a13fc --- /dev/null +++ b/frontend/src/components/graphs/StatusCount.js @@ -0,0 +1,74 @@ +import React, { useEffect } from 'react'; +import Chart from 'react-apexcharts'; +import { useParams } from 'react-router'; +import { pluck } from 'ramda'; +import { useStateValue } from '../../contexts/state'; +import { colorTypes } from '../../utils/colorTypes'; + +const StatusCount = ({ labels }) => { + const { seriesId, buildId } = useParams(); + + const [{ statusCount }, dispatch] = useStateValue(); + + useEffect(() => { + const url = `/data/series/${seriesId}/status_counts/?start_from=${buildId}&builds=1`; + + const fetchData = async () => { + dispatch({ type: 'setLoadingState', loadingState: true }); + try { + const res = await fetch(url); + const json = await res.json(); + dispatch({ type: 'setLoadingState', loadingState: false }); + const statusCount = json.status_counts; + dispatch({ type: 'setStatusCount', statusCount }); + } catch (error) { + dispatch({ type: 'setErrorState', errorState: error }); + } + }; + fetchData(); + }, [buildId, dispatch, seriesId]); + + const data = + statusCount && labels.map(label => pluck(label, statusCount)).flat(); + + const series = data; + const options = { + labels, + colors: [ + colorTypes['semolina red'], + colorTypes['pirlo blue'], + colorTypes['titan green'], + colorTypes['kumpula yellow'], + ], + plotOptions: { + pie: { + expandOnClick: true, + donut: { + labels: { + show: true, + name: { + show: true, + }, + total: { + show: true, + }, + }, + }, + }, + }, + }; + return ( +
+ {statusCount && ( + + )} +
+ ); +}; + +export default StatusCount; diff --git a/frontend/src/components/graphs/SuiteInstability.js b/frontend/src/components/graphs/SuiteInstability.js new file mode 100644 index 00000000..7fc41061 --- /dev/null +++ b/frontend/src/components/graphs/SuiteInstability.js @@ -0,0 +1,276 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router'; +import { VegaLite } from 'react-vega'; +import { useStateValue } from '../../contexts/state'; +/** @jsx jsx */ +import { css, jsx } from '@emotion/core'; +import Loading from '../Loading'; +import { colorTypes } from '../../utils/colorTypes'; + +const SuiteInstability = () => { + const canvasStyles = css` + summary { + display: none; + } + `; + + const [selectedSuite, setSelectedSuite] = useState(null); + const [ + { amountOfBuilds, historyDataState, loadingState }, + dispatch, + ] = useStateValue(); + + const { seriesId } = useParams(); + + const numberOfBuilds = amountOfBuilds || 30; // FIXME: magic + + useEffect(() => { + const url = `/data/series/${seriesId}/history?builds=${numberOfBuilds}`; + + const fetchData = async () => { + dispatch({ type: 'setLoadingState', loadingState: true }); + try { + const response = await fetch(url, {}); + const json = await response.json(); + dispatch({ + type: 'updateHistory', + historyData: json, + }); + dispatch({ type: 'setLoadingState', loadingState: false }); + } catch (error) { + dispatch({ type: 'setErrorState', errorState: error }); + } + }; + fetchData(); + }, [dispatch, numberOfBuilds, seriesId]); + + const { buildId } = useParams(); + + const correctStatus = () => (buildId ? 'build' : 'series'); + + const barSpec = { + title: 'Suites with unstable tests', + width: 400, + height: 200, + mark: { + type: 'bar', + stroke: colorTypes['gradient black'], + }, + background: colorTypes['hermanni grey'], + actions: false, + selection: { + highlight: { type: 'single', empty: 'none', on: 'mouseover' }, + select: { + type: 'single', + fields: ['id'], + encodings: ['x'], + }, + }, + encoding: { + x: { + field: 'name', + type: 'ordinal', + sort: '-y', + axis: { title: 'Suite name' }, + }, + y: { + field: 'numberOfFailingTests', + type: 'quantitative', + axis: { title: 'Number of failing tests' }, + }, + tooltip: [ + { + field: 'stability', + type: 'quantitative', + title: 'Stability', + }, + ], + fillOpacity: { + condition: { + selection: 'select', + value: 1, + }, + value: 0.3, + }, + strokeWidth: { + condition: [ + { + selection: 'highlight', + value: 1, + }, + { + test: { + and: [ + { + selection: 'select', + }, + 'length(data("select_store"))', + ], + }, + }, + ], + value: 0, + }, + color: { + field: 'stability', + type: 'quantitative', + sort: 'descending', + axis: { title: 'Stability (average)' }, + legend: { + direction: 'horizontal', + }, + }, + }, + data: { name: 'failingSuites' }, + }; + + const calculateTestStability = test => { + var states = 1; + var passes = 0; + let previousStatus; + + test['builds'].forEach(testRun => { + if (!previousStatus) { + previousStatus = testRun.status; + } else if (previousStatus !== testRun.status) { + states += 1; + } + + if (testRun.status === 'PASS') { + passes++; + } + }); + + return passes / states / test['builds'].length; + }; + + const generateBarData = historyDataState => { + const suites = []; + + historyDataState['history'].forEach(suite => { + const failingTests = []; + + suite['test_cases'].forEach(testCase => { + const isFailingTest = testCase['builds'].some(testRun => { + return testRun['status'] === 'FAIL'; + }); + if (isFailingTest) { + const stability = calculateTestStability(testCase); + failingTests.push({ name: testCase.name, stability }); + } + }); + + if (failingTests.length) { + suites.push({ + id: suite['suite_id'], + name: suite['name'], + failingTests, + }); + } + }); + + const failingSuites = suites.map(suite => { + const stability = + suite.failingTests.reduce( + (acc, test) => acc + test.stability, + 0 + ) / suite.failingTests.length; + + return { + name: suite.name, + numberOfFailingTests: suite.failingTests.length, + stability, + id: suite.id, + }; + }); + + return { failingSuites }; + }; + + if (!historyDataState || loadingState) { + return ; + } + + const selectSuite = id => { + const suite = historyDataState['history'].find( + suite => suite['suite_id'] === id + ); + + if (suite) { + setSelectedSuite(suite); + } + }; + + const deselectSuite = () => setSelectedSuite(null); + + const handleBarChartClick = (_name, values) => { + if (values.id) { + selectSuite(values.id[0]); + } else { + deselectSuite(); + } + }; + + const signalListeners = { select: handleBarChartClick }; + + const barData = generateBarData(historyDataState); + + const buildsInTotal = Math.min( + historyDataState['max_build_num'], + numberOfBuilds + ); + + const generateStatusRow = testCase => { + let statuses = []; + + for (let i = 0; i < buildsInTotal; i++) { + const testStatus = testCase['builds'].find(build => { + return build['build_number'] === i + 1; + }); + + if (testStatus) { + if (testStatus['status'] === 'PASS') { + statuses.push('x'); + } else if (testStatus['status'] === 'FAIL') { + statuses.push('o'); + } + } else { + // Impute + statuses.push('_'); + } + } + + return statuses; + }; + + return ( + + + {selectedSuite && ( + + + + + + + + + {selectedSuite['test_cases'].map(testCase => ( + + + + + ))} + +
{selectedSuite.name}Test history
{testCase.name}{generateStatusRow(testCase)}
+ )} +
+ ); +}; + +export default SuiteInstability; diff --git a/frontend/src/components/historyTable/Body.js b/frontend/src/components/historyTable/Body.js index ef6e8398..2b41fcb8 100644 --- a/frontend/src/components/historyTable/Body.js +++ b/frontend/src/components/historyTable/Body.js @@ -7,8 +7,8 @@ const Body = () => { const [ { historyDataState: { history }, - amountOfBuilds - } + amountOfBuilds, + }, ] = useStateValue(); const queryParams = useQueryParams(); diff --git a/frontend/src/components/historyTable/Filter.js b/frontend/src/components/historyTable/Filter.js index dcf8f1bb..2bd9c8d5 100644 --- a/frontend/src/components/historyTable/Filter.js +++ b/frontend/src/components/historyTable/Filter.js @@ -27,8 +27,8 @@ const Filter = () => { } .selected { background-color: transparent; - border: 2px solid var(--revolution-black); - color: var(--revolution-black); + border: 2px solid var(--gradient-black); + color: var(--gradient-black); } .button-group { display: flex; @@ -38,7 +38,7 @@ const Filter = () => { border: 1px solid #eee; width: 100px; border-radius: 10px; - background-color: var(--powder-white); + background-color: var(--nero-white); padding: 5px; margin: 5px; cursor: pointer; @@ -76,7 +76,7 @@ const FilterButton = ({ title }) => { history.push({ pathname: `${location.pathname}`, search: `?${updateTags(e.target.value)}`, - state: {} + state: {}, }); }; diff --git a/frontend/src/components/historyTable/Table.js b/frontend/src/components/historyTable/Table.js index 31cc72ec..02cad728 100644 --- a/frontend/src/components/historyTable/Table.js +++ b/frontend/src/components/historyTable/Table.js @@ -28,10 +28,10 @@ const Table = () => { background: #ddd; } td { - background: var(--powder-white); + background: var(--nero-white); } td.test-result-undefined { - background: var(--mithril-grey); + background: var(--hermanni-grey); } .centerTableCellContent { text-align: center; @@ -40,8 +40,8 @@ const Table = () => { `; const [ { - historyDataState: { max_build_num } - } + historyDataState: { max_build_num }, + }, ] = useStateValue(); if (max_build_num > 0) { diff --git a/frontend/src/components/parentData/ParentBuild.js b/frontend/src/components/parentData/ParentBuild.js index 268931c3..37c83ad5 100644 --- a/frontend/src/components/parentData/ParentBuild.js +++ b/frontend/src/components/parentData/ParentBuild.js @@ -9,9 +9,9 @@ const ParentSeries = () => { const { seriesId, buildId, testId } = useParams(); const [ { - parentData: { buildData } + parentData: { buildData }, }, - dispatch + dispatch, ] = useStateValue(); useEffect(() => { diff --git a/frontend/src/contexts/reducer.js b/frontend/src/contexts/reducer.js index bc773e78..56c5dd70 100644 --- a/frontend/src/contexts/reducer.js +++ b/frontend/src/contexts/reducer.js @@ -3,68 +3,68 @@ const reducer = (state, action) => { case 'updateHistory': return { ...state, - historyDataState: action.historyData + historyDataState: action.historyData, }; case 'setAmountOfBuilds': return { ...state, - amountOfBuilds: action.amountOfBuilds + amountOfBuilds: action.amountOfBuilds, }; case 'setLoadingState': return { ...state, - loadingState: action.loadingState + loadingState: action.loadingState, }; case 'setErrorState': return { ...state, - errorState: action.errorState + errorState: action.errorState, }; case 'setHistoryFilterType': return { ...state, historyFilter: { filterType: action.filterType, - isChecked: action.isChecked - } + isChecked: action.isChecked, + }, }; case 'setHistoryFilterPass': return { ...state, historyFilterPass: { filterType: action.filterType, - isChecked: action.isChecked - } + isChecked: action.isChecked, + }, }; case 'setHistoryFilterFail': return { ...state, historyFilterFail: { filterType: action.filterType, - isChecked: action.isChecked - } + isChecked: action.isChecked, + }, }; case 'setLastRunFilterFail': return { ...state, lastRunFilterFail: { filterType: action.filterType, - isChecked: action.isChecked - } + isChecked: action.isChecked, + }, }; case 'setLastRunFilterPass': return { ...state, lastRunFilterPass: { filterType: action.filterType, - isChecked: action.isChecked - } + isChecked: action.isChecked, + }, }; case 'setBranches': return { ...state, - branchesState: action.branches + branchesState: action.branches, }; case 'setSelectedBranch': return { @@ -72,44 +72,49 @@ const reducer = (state, action) => { selectedBranchState: { name: action.name, id: action.id, - team: action.team - } + team: action.team, + }, }; case 'setMetadata': return { ...state, - metadataState: action.metadata + metadataState: action.metadata, }; case 'setSelectedBuild': return { ...state, - selectedBuildState: action.selectedBuild + selectedBuildState: action.selectedBuild, }; case 'setTeams': return { ...state, - teamsState: action.teams + teamsState: action.teams, }; case 'setSelectedSuiteState': return { ...state, - selectedSuiteState: action.suite + selectedSuiteState: action.suite, }; case 'setSeriesData': return { ...state, parentData: { ...state.parentData, - seriesData: action.seriesData - } + seriesData: action.seriesData, + }, }; case 'setBuildData': return { ...state, parentData: { ...state.parentData, - buildData: action.buildData - } + buildData: action.buildData, + }, + }; + case 'setStatusCount': + return { + ...state, + statusCount: action.statusCount, }; default: return state; diff --git a/frontend/src/contexts/state.js b/frontend/src/contexts/state.js index 1bb3b64e..b1cec911 100644 --- a/frontend/src/contexts/state.js +++ b/frontend/src/contexts/state.js @@ -10,11 +10,11 @@ const initialState = { amountFilteredData: null, lastRunFilterFail: { isChecked: false, - filterType: '' + filterType: '', }, lastRunFilterPass: { isChecked: false, - filterType: '' + filterType: '', }, branchesState: null, selectedBranchState: { name: 'All builds', id: 1 }, @@ -22,8 +22,9 @@ const initialState = { selectedBuildState: {}, parentData: { seriesData: null, - buildData: null - } + buildData: null, + }, + statusCount: null, }; export const StateProvider = ({ reducer, children }) => { diff --git a/frontend/src/index.css b/frontend/src/index.css index 27f9a63d..8eebf432 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -21,12 +21,16 @@ html { ----------------------*/ :root { --nelson-purple: #b8557b; - --whipped-red: #c63757; + --semolina-red: #c63757; --pirlo-blue: #56a2c3; - --revolution-black: #141312; - --dove-grey: #7b756f; - --mithril-grey: #e9e8e8; - --powder-white: #ffffff; + --titan-green: #2e8d6e; + --gradient-black: #141312; + --evidence-grey: #7b756f; + --hermanni-grey: #edecec; + --toukola-green: #e9f5e1; + --kumpula-yellow: #faf3e1; + --vallila-blue: #e1f1f7; + --nero-white: #ffffff; } /** Basics **/ diff --git a/frontend/src/pages/Build.js b/frontend/src/pages/Build.js index 1b268f11..2d9cb64f 100644 --- a/frontend/src/pages/Build.js +++ b/frontend/src/pages/Build.js @@ -26,7 +26,7 @@ const Build = () => { `; const [ { loadingState, historyDataState, selectedBranchState, branchesState }, - dispatch + dispatch, ] = useStateValue(); let { buildId, seriesId } = useParams(); @@ -45,7 +45,7 @@ const Build = () => { dispatch({ type: 'setLoadingState', loadingState: false }); dispatch({ type: 'setMetadata', - metadata: json + metadata: json, }); } catch (error) { //console.log(error); @@ -62,7 +62,7 @@ const Build = () => { type: 'setSelectedBranch', name: branch?.name, id: branch_id, - team: branch?.team || ' ' + team: branch?.team || ' ', }); dispatch({ type: 'setSelectedBuild', selectedBuild: buildId }); try { @@ -74,7 +74,7 @@ const Build = () => { dispatch({ type: 'setLoadingState', loadingState: false }); dispatch({ type: 'updateHistory', - historyData: json + historyData: json, }); } catch (error) { dispatch({ type: 'setErrorState', errorState: error }); diff --git a/frontend/src/pages/Dashboard.js b/frontend/src/pages/Dashboard.js index 3651402f..d9dd3348 100644 --- a/frontend/src/pages/Dashboard.js +++ b/frontend/src/pages/Dashboard.js @@ -1,17 +1,37 @@ import React from 'react'; -import { useParams } from 'react-router'; +import { useLocation } from 'react-router-dom'; +/** @jsx jsx */ +import { css, jsx } from '@emotion/core'; +import SuiteInstability from '../components/graphs/SuiteInstability'; +import StatusCount from '../components/graphs/StatusCount'; import BreadcrumbNav from '../components/BreadcrumbNav'; +import { suiteLabels, testLabels } from '../utils/graphTypes'; const Dashboard = () => { - const { buildId } = useParams(); + const pathname = useLocation().pathname; + const buildUrl = pathname.includes('build'); - const correctStatus = () => (buildId ? 'build' : 'series'); + const status = buildUrl ? 'build' : 'series'; + + const dashBoardStyles = css` + .pieContainer { + padding: 20px; + display: flex; + flex-wrap: wrap; + } + `; return ( -
- - Dashboard -
+
+ + {buildUrl && ( +
+ {' '} + +
+ )} + +
); }; diff --git a/frontend/src/pages/History.js b/frontend/src/pages/History.js index f77d587f..110b89d1 100644 --- a/frontend/src/pages/History.js +++ b/frontend/src/pages/History.js @@ -37,9 +37,9 @@ const History = () => { historyDataState, selectedBranchState, amountOfBuilds, - branchesState + branchesState, }, - dispatch + dispatch, ] = useStateValue(); const { seriesId } = useParams(); const queryParams = useQueryParams(); @@ -57,20 +57,20 @@ const History = () => { dispatch({ type: 'setLoadingState', loadingState: true }); dispatch({ type: 'setAmountOfBuilds', - amountOfBuilds: number_of_builds + amountOfBuilds: number_of_builds, }); dispatch({ type: 'setSelectedBranch', name: branch?.name || ' ', id: series_id, - team: branch?.team || ' ' + team: branch?.team || ' ', }); try { const res = await fetch(url, {}); const json = await res.json(); dispatch({ type: 'updateHistory', - historyData: json + historyData: json, }); dispatch({ type: 'setLoadingState', loadingState: false }); } catch (error) { diff --git a/frontend/src/utils/colorTypes.js b/frontend/src/utils/colorTypes.js new file mode 100644 index 00000000..a9948df1 --- /dev/null +++ b/frontend/src/utils/colorTypes.js @@ -0,0 +1,13 @@ +export const colorTypes = { + 'gradient black': '#141312', + 'evidence grey': '#7B756F', + 'nero white': '#ffffff', + 'nelson purple': '#b8557b', + 'semolina red': '#C63757', + 'pirlo blue': '#56a2c3', + 'titan green': '#2E8D6E', + 'kumpula yellow': '#FAF3E1', + 'toukola green': '#E9F5E1', + 'vallila blue': '#E1F1F7', + 'hermanni grey': '#EDECEC', +}; diff --git a/frontend/src/utils/graphTypes.js b/frontend/src/utils/graphTypes.js new file mode 100644 index 00000000..35128055 --- /dev/null +++ b/frontend/src/utils/graphTypes.js @@ -0,0 +1,13 @@ +export const suiteLabels = [ + 'suites_failed', + 'suites_other', + 'suites_passed', + 'suites_skipped', +]; + +export const testLabels = [ + 'tests_failed', + 'tests_other', + 'tests_passed', + 'tests_skipped', +]; diff --git a/frontend/src/utils/parentDataTypes.js b/frontend/src/utils/parentDataTypes.js index a467a622..6ebc3641 100644 --- a/frontend/src/utils/parentDataTypes.js +++ b/frontend/src/utils/parentDataTypes.js @@ -5,12 +5,12 @@ export const buildTypes = [ 'build_number', 'build_id', 'status', - 'start_time' + 'start_time', ]; export const suiteTypes = [ 'team', 'name', 'build_number', 'build_id', - 'start_time' + 'start_time', ];