From c8fef183079069841d12a66a5033e04deb3073f3 Mon Sep 17 00:00:00 2001 From: john-dupuy Date: Fri, 14 Jan 2022 11:21:12 -0800 Subject: [PATCH] Add test history tab to Result page (#276) * Add test history tab to Result page * Get rid of buggy Tooltip --- backend/ibutsu_server/__init__.py | 2 +- frontend/src/components/classify-failures.js | 19 +- frontend/src/components/result.js | 25 +- frontend/src/components/runsummary.js | 3 + frontend/src/components/test-history.js | 320 +++++++++++++++++++ frontend/src/run-list.js | 48 +-- frontend/src/utilities.js | 47 ++- 7 files changed, 400 insertions(+), 64 deletions(-) create mode 100644 frontend/src/components/test-history.js diff --git a/backend/ibutsu_server/__init__.py b/backend/ibutsu_server/__init__.py index 811256c1..7d23f8c1 100644 --- a/backend/ibutsu_server/__init__.py +++ b/backend/ibutsu_server/__init__.py @@ -63,7 +63,7 @@ def get_app(**extra_config): config.from_mapping(os.environ) # convert str to bool for USER_LOGIN_ENABLED if isinstance(config.get("USER_LOGIN_ENABLED", True), str): - config["USER_LOGIN_ENABLED"] = config["USER_LOGIN_ENABLED"][0] in ["y", "t", "1"] + config["USER_LOGIN_ENABLED"] = config["USER_LOGIN_ENABLED"].lower()[0] in ["y", "t", "1"] if config.get("POSTGRESQL_HOST") and config.get("POSTGRESQL_DATABASE"): # If you have environment variables, like when running on OpenShift, create the db url config.update( diff --git a/frontend/src/components/classify-failures.js b/frontend/src/components/classify-failures.js index 67355cdc..1e11199c 100644 --- a/frontend/src/components/classify-failures.js +++ b/frontend/src/components/classify-failures.js @@ -30,6 +30,7 @@ import { FilterTable, MultiClassificationDropdown, MetaFilter, + ResultView } from './index'; @@ -71,10 +72,22 @@ export class ClassifyFailuresTable extends React.Component { onCollapse(event, rowIndex, isOpen) { const { rows } = this.state; + + // lazy-load the result view so we don't have to make a bunch of artifact requests + if (isOpen) { + let result = rows[rowIndex].result; + let hideSummary=true; + let hideTestObject=true; + if (result.result === "skipped") { + hideSummary=false; + hideTestObject=false; + } + rows[rowIndex + 1].cells = [{ + title: + }] + } rows[rowIndex].isOpen = isOpen; - this.setState({ - rows - }); + this.setState({rows}); } onTableRowSelect = (event, isSelected, rowId) => { diff --git a/frontend/src/components/result.js b/frontend/src/components/result.js index 5a158998..5ca89235 100644 --- a/frontend/src/components/result.js +++ b/frontend/src/components/result.js @@ -17,7 +17,7 @@ import { Tabs, Tab } from '@patternfly/react-core'; -import { FileAltIcon, FileImageIcon, InfoCircleIcon, CodeIcon } from '@patternfly/react-icons'; +import { FileAltIcon, FileImageIcon, InfoCircleIcon, CodeIcon, SearchIcon } from '@patternfly/react-icons'; import { Link } from 'react-router-dom'; import Linkify from 'react-linkify'; import ReactJson from 'react-json-view'; @@ -29,6 +29,7 @@ import { linkifyDecorator } from './decorators' import { Settings } from '../settings'; import { getIconForResult, round } from '../utilities'; import { TabTitle } from './tabs'; +import { TestHistoryTable } from './test-history'; const MockTest = { id: null, @@ -60,6 +61,7 @@ export class ResultView extends React.Component { resultId: PropTypes.string, hideSummary: PropTypes.bool, hideTestObject: PropTypes.bool, + hideTestHistory: PropTypes.bool, history: PropTypes.object, location: PropTypes.object } @@ -71,7 +73,8 @@ export class ResultView extends React.Component { id: this.props.resultId || null, artifacts: [], activeTab: this.getTabIndex(this.getDefaultTab()), - artifactTabs: [] + artifactTabs: [], + testHistoryTable: null, }; if (this.props.history) { // Watch the history to update tabs @@ -103,6 +106,12 @@ export class ResultView extends React.Component { } } + updateTab(tabIndex) { + if (tabIndex === 'test-history') { + this.getTestHistoryTable(); + } + } + onTabSelect = (event, tabIndex) => { if (this.props.history) { const loc = this.props.history.location; @@ -113,8 +122,13 @@ export class ResultView extends React.Component { }); } this.setState({activeTab: tabIndex}); + this.updateTab(tabIndex); }; + getTestHistoryTable = () => { + this.setState({testHistoryTable: }); + } + getTestResult(resultId) { HttpClient.get([Settings.serverUrl, 'result', resultId]) .then(response => HttpClient.handleResponse(response)) @@ -200,7 +214,7 @@ export class ResultView extends React.Component { } render() { - let { testResult, artifactTabs, activeTab } = this.state; + let { testResult, artifactTabs, activeTab, testHistoryTable } = this.state; if (activeTab === null) { activeTab = this.getDefaultTab(); } @@ -487,6 +501,11 @@ export class ResultView extends React.Component { } {artifactTabs} + {!this.props.hideTestHistory && + } style={{backgroundColor: "white"}}> + {testHistoryTable} + + } {!this.props.hideTestObject && } style={{backgroundColor: "white"}}> diff --git a/frontend/src/components/runsummary.js b/frontend/src/components/runsummary.js index f04878eb..e5f3ea24 100644 --- a/frontend/src/components/runsummary.js +++ b/frontend/src/components/runsummary.js @@ -34,6 +34,9 @@ export class RunSummary extends React.Component { passed -= summary.xpasses; xpassed = summary.xpasses; } + if (summary.passes) { + passed = summary.passes; + } return ( {passed > 0 && {passed}} diff --git a/frontend/src/components/test-history.js b/frontend/src/components/test-history.js new file mode 100644 index 00000000..a60b4e55 --- /dev/null +++ b/frontend/src/components/test-history.js @@ -0,0 +1,320 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + Button, + Card, + CardHeader, + CardBody, + Checkbox, + Dropdown, + DropdownItem, + DropdownToggle, + Flex, + FlexItem, + TextContent, + Text, +} from '@patternfly/react-core'; +import { + TableVariant, + expandable +} from '@patternfly/react-table'; + +import { HttpClient } from '../services/http'; +import { Settings } from '../settings'; +import { + buildParams, + toAPIFilter, + getSpinnerRow, + resultToTestHistoryRow, +} from '../utilities'; +import { + FilterTable, + ResultView, + RunSummary +} from './index'; + + +export class TestHistoryTable extends React.Component { + static propTypes = { + filters: PropTypes.object, + testResult: PropTypes.object, + } + + constructor(props) { + super(props); + this.state = { + columns: [{title: 'Result', cellFormatters: [expandable]}, 'Source', 'Exception Name', 'Duration', 'Start Time'], + rows: [getSpinnerRow(5)], + results: [], + cursor: null, + pageSize: 10, + page: 1, + totalItems: 0, + totalPages: 0, + isEmpty: false, + isError: false, + isFieldOpen: false, + isOperationOpen: false, + isDropdownOpen: false, + onlyFailures: false, + historySummary: null, + dropdownSelection: '1 Week', + filters: Object.assign({ + 'result': {op: 'in', val: "passed;skipped;failed;error;xpassed;xfailed"}, + 'test_id': {op: 'eq', val: props.testResult.test_id}, + 'env': {op: 'eq', val: props.testResult.env}, + // default to filter only from 1 weeks ago to the most test's start_time. + 'start_time': {op: 'gt', val: new Date(new Date(props.testResult.start_time).getTime() - (0.25 * 30 * 86400 * 1000)).toISOString()} + }, props.filters), + }; + this.refreshResults = this.refreshResults.bind(this); + this.onCollapse = this.onCollapse.bind(this); + } + + refreshResults = () => { + this.getResultsForTable(); + } + + onCollapse(event, rowIndex, isOpen) { + const { rows } = this.state; + + // lazy-load the result view so we don't have to make a bunch of artifact requests + if (isOpen) { + let result = rows[rowIndex].result; + let hideSummary=true; + let hideTestObject=true; + if (["passed", "skipped"].includes(result.result)) { + hideSummary=false; + hideTestObject=false; + } + rows[rowIndex + 1].cells = [{ + title: + }] + } + rows[rowIndex].isOpen = isOpen; + this.setState({rows}); + } + + setPage = (_event, pageNumber) => { + this.setState({page: pageNumber}, () => { + this.getResultsForTable(); + }); + } + + pageSizeSelect = (_event, perPage) => { + this.setState({pageSize: perPage}, () => { + this.getResultsForTable(); + }); + } + + updateFilters(name, operator, value, callback) { + let filters = this.state.filters; + if ((value === null) || (value.length === 0)) { + delete filters[name]; + } + else { + filters[name] = {'op': operator, 'val': value}; + } + this.setState({filters: filters, page: 1}, callback); + } + + setFilter = (field, value) => { + // maybe process values array to string format here instead of expecting caller to do it? + let operator = (value.includes(";")) ? 'in' : 'eq' + this.updateFilters(field, operator, value, this.refreshResults) + } + + removeFilter = id => { + if ((id !== "result") && (id !== "test_id")) { // Don't allow removal of error/failure filter + this.updateFilters(id, null, null, this.refreshResults) + } + } + + onFailuresCheck = (checked) => { + let { filters } = this.state; + filters["result"]["val"] = ("failed;error") + ((checked) ? ";skipped;xfailed" : ";skipped;xfailed;xpassed;passed") + this.setState( + {onlyFailures: checked, filters}, + this.refreshResults + ); + } + + onDropdownToggle = isOpen => { + this.setState({isDropdownOpen: isOpen}); + } + + onDropdownSelect = event => { + let { filters } = this.state; + let { testResult } = this.props; + let startTime = new Date(testResult.start_time); + let months = event.target.getAttribute('value'); + let selection = event.target.text + // here a month is considered to be 30 days, and there are 86400*1000 ms in a day + let timeRange = new Date(startTime.getTime() - (months * 30 * 86400 * 1000)); + // set the filters + filters["start_time"] = {op: "gt", val: timeRange.toISOString()} + this.setState({filters, isDropdownOpen: false, dropdownSelection: selection}, this.refreshResults); + } + + getHistorySummary() { + // get the passed/failed/etc test summary + let filters = {... this.state.filters}; + // disregard result filter (we want all results) + delete filters["result"]; + let api_filter = toAPIFilter(filters).join() + let dataToSummary = Object.assign({ + 'passed': 'passes', + 'failed': 'failures', + 'error': 'errors', + 'skipped': 'skips', + 'xfailed': 'xfailures', + 'xpassed': 'xpasses' + }) + let summary = Object.assign({ + "passes": 0, + "failures": 0, + "errors": 0, + "skips": 0, + "xfailures": 0, + "xpasses": 0 + }); + + HttpClient.get( + [Settings.serverUrl, 'widget', 'result-aggregator'], + { + group_field: 'result', + additional_filters: api_filter, + } + ) + .then(response => HttpClient.handleResponse(response)) + .then(data => { + data.forEach(item => { + summary[dataToSummary[item['_id']]] = item['count'] + }) + this.setState({historySummary: summary}) + }) + } + + getResultsForTable() { + const filters = this.state.filters; + this.setState({rows: [getSpinnerRow(4)], isEmpty: false, isError: false}); + // get only failed results + let params = buildParams(filters); + params['filter'] = toAPIFilter(filters); + params['pageSize'] = this.state.pageSize; + params['page'] = this.state.page; + + this.setState({rows: [['Loading...', '', '', '', '']]}); + HttpClient.get([Settings.serverUrl, 'result'], params) + .then(response => HttpClient.handleResponse(response)) + .then(data => this.setState({ + results: data.results, + rows: data.results.map((result, index) => resultToTestHistoryRow(result, index, this.setFilter)).flat(), + page: data.pagination.page, + pageSize: data.pagination.pageSize, + totalItems: data.pagination.totalItems, + totalPages: data.pagination.totalPages, + isEmpty: data.pagination.totalItems === 0, + }, this.getHistorySummary)) + .catch((error) => { + console.error('Error fetching result data:', error); + this.setState({rows: [], isEmpty: false, isError: true}); + }); + } + + componentDidMount() { + this.getResultsForTable(); + } + + render() { + const { + columns, + rows, + onlyFailures, + historySummary, + dropdownSelection + } = this.state; + const pagination = { + pageSize: this.state.pageSize, + page: this.state.page, + totalItems: this.state.totalItems + } + const dropdownValues = Object.assign({ + "1 Week": 0.25, + "2 Weeks": 0.5, + "1 Month": 1.0, + "2 Months": 2.0, + "3 Months": 3.0, + "5 Months": 5.0 + }) + let dropdownItems = []; + Object.keys(dropdownValues).forEach(key => { + dropdownItems.push( + + {key} + + ) + }); + + return ( + + + + + + + Test History + + + + + + + Summary:  + {historySummary && + + } + + + + + + + + + + Time range} + onSelect={this.onDropdownSelect} + isOpen={this.state.isDropdownOpen} + dropdownItems={dropdownItems} + /> + + + + + + + + + + + ); + } + +} diff --git a/frontend/src/run-list.js b/frontend/src/run-list.js index 90f2b153..8a31286e 100644 --- a/frontend/src/run-list.js +++ b/frontend/src/run-list.js @@ -30,55 +30,9 @@ import { parseFilter, round } from './utilities'; -import { MultiValueInput, FilterTable } from './components'; +import { MultiValueInput, FilterTable, RunSummary } from './components'; import { OPERATIONS, RUN_FIELDS } from './constants'; -export class RunSummary extends React.Component { - static propTypes = { - summary: PropTypes.object - } - - render() { - if (!this.props.summary) { - return ''; - } - const summary = this.props.summary; - let passed = 0, failed = 0, errors = 0, skipped = 0, xfailed = 0, xpassed = 0; - if (summary.tests) { - passed = summary.tests; - } - if (summary.failures) { - passed -= summary.failures; - failed = summary.failures; - } - if (summary.errors) { - passed -= summary.errors; - errors = summary.errors; - } - if (summary.skips) { - passed -= summary.skips; - skipped = summary.skips; - } - if (summary.xfailures) { - passed -= summary.xfailures; - xfailed = summary.xfailures; - } - if (summary.xpasses) { - passed -= summary.xpasses; - xpassed = summary.xpasses; - } - return ( - - {passed > 0 && {passed}} - {failed > 0 && {failed}} - {errors > 0 && {errors}} - {skipped > 0 && {skipped}} - {xfailed > 0 && {xfailed}} - {xpassed > 0 && {xpassed}} - - ); - } -} function runToRow(run, filterFunc) { let badges = []; diff --git a/frontend/src/utilities.js b/frontend/src/utilities.js index 2371bc00..f03ddb8d 100644 --- a/frontend/src/utilities.js +++ b/frontend/src/utilities.js @@ -29,7 +29,7 @@ import { NUMERIC_RESULT_FIELDS, NUMERIC_RUN_FIELDS, } from './constants'; -import { ClassificationDropdown, ResultView } from './components'; +import { ClassificationDropdown } from './components'; export function getIconForResult(result) { let resultIcon = ''; @@ -201,8 +201,6 @@ export function resultToRow(result, filterFunc) { export function resultToClassificationRow(result, index, filterFunc) { let resultIcon = getIconForResult(result.result); - let hideSummary = true; - let hideTestObject = true; let markers = []; let exceptionBadge; @@ -226,15 +224,11 @@ export function resultToClassificationRow(result, index, filterFunc) { } } - if (result.result === "skipped") { - hideSummary=false; - hideTestObject=false; - } - return [ // parent row { "isOpen": false, + "result": result, "cells": [ {title: {result.test_id} {markers}}, {title: {resultIcon} {toTitleCase(result.result)}}, @@ -243,10 +237,43 @@ export function resultToClassificationRow(result, index, filterFunc) { {title: round(result.duration) + 's'}, ], }, - // child row + // child row (this is set in the onCollapse function for lazy-loading) + { + "parent": 2*index, + "cells": [{title:
}] + } + ]; +} + +export function resultToTestHistoryRow(result, index, filterFunc) { + let resultIcon = getIconForResult(result.result); + let exceptionBadge; + + if (filterFunc) { + exceptionBadge = buildBadge('exception_name', result.metadata.exception_name, false, + () => filterFunc('metadata.exception_name', result.metadata.exception_name)); + } + else { + exceptionBadge = buildBadge('exception_name', result.metadata.exception_name, false); + } + + return [ + // parent row + { + "isOpen": false, + "result": result, + "cells": [ + {title: {resultIcon} {toTitleCase(result.result)}}, + {title: {result.source}}, + {title: {exceptionBadge}}, + {title: round(result.duration) + 's'}, + {title: (new Date(result.start_time).toLocaleString())}, + ], + }, + // child row (this is set in the onCollapse function for lazy-loading) { "parent": 2*index, - "cells": [{title: }] + "cells": [{title:
}] } ]; }