UI: add pagination to builds, buildsets search

Allow the user to see more than 50 results, and paginate the
results.

Depends-On: I5a64e16260399062ae341ca6026893d80391c668

Change-Id: I83dff8de93ff015773dd6d83ea406295ad5c48d9
This commit is contained in:
Matthieu Huin 2021-05-27 18:08:24 +02:00
parent 7f4475fcce
commit 38241ad29a
12 changed files with 223 additions and 61 deletions

View File

@ -0,0 +1,6 @@
---
features:
- |
REST API calls to the builds and buildsets endpoints now return pagination
information (total results and offset). This is an API-breaking change.
Implement results pagination in the web UI (buildsets and builds pages).

View File

@ -100,8 +100,9 @@ class TestMysqlDatabase(BaseTestCase):
db.createBuildSet(**buildset_args)
# Verify that worked using the driver-external interface
self.assertEqual(len(self.connection.getBuildsets()), 1)
self.assertEqual(self.connection.getBuildsets()[0].uuid, buildset_uuid)
results = self.connection.getBuildsets()
self.assertEqual(results['total'], 1)
self.assertEqual(results['buildsets'][0].uuid, buildset_uuid)
# Update the buildset using the internal interface
with self.connection.getSession() as db:

View File

@ -1271,11 +1271,12 @@ class TestBuildInfo(BaseTestWeb):
self.waitUntilSettled()
builds = self.get_url("api/tenant/tenant-one/builds").json()
self.assertEqual(len(builds), 6)
self.assertEqual(builds['total'], 6)
self.assertEqual(len(builds['builds']), 6)
uuid = builds[0]['uuid']
uuid = builds['builds'][0]['uuid']
build = self.get_url("api/tenant/tenant-one/build/%s" % uuid).json()
self.assertEqual(build['job_name'], builds[0]['job_name'])
self.assertEqual(build['job_name'], builds['builds'][0]['job_name'])
resp = self.get_url("api/tenant/tenant-one/build/1234")
self.assertEqual(404, resp.status_code)
@ -1283,8 +1284,9 @@ class TestBuildInfo(BaseTestWeb):
builds_query = self.get_url("api/tenant/tenant-one/builds?"
"project=org/project&"
"project=org/project1").json()
self.assertEqual(len(builds_query), 6)
self.assertEqual(builds_query[0]['nodeset'], 'test-nodeset')
self.assertEqual(builds_query['total'], 6)
self.assertEqual(len(builds_query['builds']), 6)
self.assertEqual(builds_query['builds'][0]['nodeset'], 'test-nodeset')
resp = self.get_url("api/tenant/non-tenant/builds")
self.assertEqual(404, resp.status_code)
@ -1330,8 +1332,10 @@ class TestBuildInfo(BaseTestWeb):
self.waitUntilSettled()
buildsets = self.get_url("api/tenant/tenant-one/buildsets").json()
self.assertEqual(2, len(buildsets))
project_bs = [x for x in buildsets if x["project"] == "org/project"][0]
self.assertEqual(2, buildsets['total'])
self.assertEqual(2, len(buildsets['buildsets']))
project_bs = [x for x in buildsets['buildsets']
if x["project"] == "org/project"][0]
buildset = self.get_url(
"api/tenant/tenant-one/buildset/%s" % project_bs['uuid']).json()
@ -1370,8 +1374,9 @@ class TestBuildInfo(BaseTestWeb):
self.fake_gerrit.addEvent(A.getPatchsetCreatedEvent(1))
self.waitUntilSettled()
builds = self.get_url("api/tenant/tenant-one/builds").json()
self.assertTrue(builds['total'] >= 1)
self.assertIn('Unable to find playbook',
builds[0]['error_detail'])
builds['builds'][0]['error_detail'])
class TestArtifacts(BaseTestWeb, AnsibleZuulTestCase):
@ -1388,11 +1393,12 @@ class TestArtifacts(BaseTestWeb, AnsibleZuulTestCase):
build_query = self.get_url("api/tenant/tenant-one/builds?"
"project=org/project&"
"job_name=project-test1").json()
self.assertEqual(len(build_query), 1)
self.assertEqual(len(build_query[0]['artifacts']), 3)
arts = build_query[0]['artifacts']
self.assertEqual(build_query['total'], 1)
self.assertEqual(len(build_query['builds']), 1)
self.assertEqual(len(build_query['builds'][0]['artifacts']), 3)
arts = build_query['builds'][0]['artifacts']
arts.sort(key=lambda x: x['name'])
self.assertEqual(build_query[0]['artifacts'], [
self.assertEqual(build_query['builds'][0]['artifacts'], [
{'url': 'http://example.com/docs',
'name': 'docs'},
{'url': 'http://logs.example.com/build/relative/docs',
@ -1409,7 +1415,8 @@ class TestArtifacts(BaseTestWeb, AnsibleZuulTestCase):
self.waitUntilSettled()
buildsets = self.get_url("api/tenant/tenant-one/buildsets").json()
project_bs = [x for x in buildsets if x["project"] == "org/project"][0]
project_bs = [x for x in buildsets['buildsets']
if x["project"] == "org/project"][0]
buildset = self.get_url(
"api/tenant/tenant-one/buildset/%s" % project_bs['uuid']).json()
self.assertEqual(3, len(buildset["builds"]))
@ -2408,8 +2415,9 @@ class TestHeldAttributeInBuildInfo(BaseTestWeb):
held_builds_resp.text)
all_builds = all_builds_resp.json()
held_builds = held_builds_resp.json()
self.assertEqual(len(held_builds), 1, all_builds)
held_build = held_builds[0]
self.assertEqual(held_builds['total'], 1, all_builds)
self.assertEqual(len(held_builds['builds']), 1, all_builds)
held_build = held_builds['builds'][0]
self.assertEqual('project-test2', held_build['job_name'], held_build)
self.assertEqual(True, held_build['held'], held_build)

View File

@ -125,9 +125,18 @@ paths:
application/json:
schema:
description: The list of builds
items:
$ref: '#/components/schemas/build'
type: array
properties:
total:
type: integer
description: the total amount of builds matching the search query
offset:
type: integer
description: how many results were omitted for pagination
builds:
items:
$ref: '#/components/schemas/build'
type: array
type: object
description: Returns the list of builds
'404':
description: Tenant not found
@ -205,9 +214,18 @@ paths:
application/json:
schema:
description: The list of buildsets
items:
$ref: '#/components/schemas/buildset'
type: array
properties:
total:
type: integer
description: the total amount of buildsets matching the search query
offset:
type: integer
description: how many results were omitted for pagination
buildsets:
items:
$ref: '#/components/schemas/buildset'
type: array
type: object
description: Returns the list of builds
'404':
description: Tenant not found

View File

@ -234,7 +234,7 @@ FilterToolbar.propTypes = {
function getFiltersFromUrl(location, filterCategories) {
const urlParams = new URLSearchParams(location.search)
const filters = filterCategories.reduce((filterDict, item) => {
const _filters = filterCategories.reduce((filterDict, item) => {
// Initialize each filter category with an empty list
filterDict[item.key] = []
@ -257,6 +257,11 @@ function getFiltersFromUrl(location, filterCategories) {
})
return filterDict
}, {})
const pagination_options = {
skip: urlParams.getAll('skip') ? urlParams.getAll('skip') : [0,],
limit: urlParams.getAll('limit') ? urlParams.getAll('limit') : [50,],
}
const filters = { ..._filters, ...pagination_options }
return filters
}

View File

@ -139,13 +139,13 @@ function BuildTable({
.format('YYYY-MM-DD HH:mm:ss'),
},
{
title: (
<BuildResult
result={build.result}
link={`${tenant.linkPrefix}/build/${build.uuid}`}
colored={build.voting}
/>
),
title: (
<BuildResult
result={build.result}
link={`${tenant.linkPrefix}/build/${build.uuid}`}
colored={build.voting}
/>
),
},
],
}
@ -183,7 +183,7 @@ function BuildTable({
// fetcihng row.
columns[0].dataLabel = ''
} else {
rows = builds.map((build) => createBuildRow(build))
rows = builds.builds.map((build) => createBuildRow(build))
// This list of actions will be applied to each row in the table. For
// row-specific actions we must evaluate the individual row data provided to
// the onClick handler.
@ -234,7 +234,7 @@ function BuildTable({
}
BuildTable.propTypes = {
builds: PropTypes.array.isRequired,
builds: PropTypes.object.isRequired,
fetching: PropTypes.bool.isRequired,
onClearFilters: PropTypes.func.isRequired,
tenant: PropTypes.object.isRequired,

View File

@ -101,13 +101,13 @@ function BuildsetTable({
title: changeOrRefLink && changeOrRefLink,
},
{
title: (
<BuildResult
result={buildset.result}
link={`${tenant.linkPrefix}/buildset/${buildset.uuid}`}
>
</BuildResult>
),
title: (
<BuildResult
result={buildset.result}
link={`${tenant.linkPrefix}/buildset/${buildset.uuid}`}
>
</BuildResult>
),
},
],
}
@ -145,7 +145,7 @@ function BuildsetTable({
// the fetching row.
columns[0].dataLabel = ''
} else {
rows = buildsets.map((buildset) => createBuildsetRow(buildset))
rows = buildsets.buildsets.map((buildset) => createBuildsetRow(buildset))
// This list of actions will be applied to each row in the table. For
// row-specific actions we must evaluate the individual row data provided to
// the onClick handler.
@ -196,7 +196,7 @@ function BuildsetTable({
}
BuildsetTable.propTypes = {
buildsets: PropTypes.array.isRequired,
buildsets: PropTypes.object.isRequired,
fetching: PropTypes.bool.isRequired,
onClearFilters: PropTypes.func.isRequired,
tenant: PropTypes.object.isRequired,

View File

@ -16,7 +16,7 @@ import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import 'moment-duration-format'
import { PageSection, PageSectionVariants } from '@patternfly/react-core'
import { PageSection, PageSectionVariants, Pagination } from '@patternfly/react-core'
import { fetchBuilds } from '../api'
import {
@ -117,13 +117,27 @@ class BuildsPage extends React.Component {
},
]
const _filters = getFiltersFromUrl(props.location, this.filterCategories)
const perPage = _filters.limit[0]
? parseInt(_filters.limit[0])
: 50
const currentPage = _filters.skip[0]
? Math.floor(parseInt(_filters.skip[0] / perPage)) + 1
: 1
this.state = {
builds: [],
builds: {
builds: [],
offset: null,
total: null,
},
fetching: false,
filters: getFiltersFromUrl(props.location, this.filterCategories),
filters: _filters,
projectsFetched: false,
pipelinesFetched: false,
jobsFetched: false,
resultsPerPage: perPage,
currentPage: currentPage,
}
}
@ -187,6 +201,7 @@ class BuildsPage extends React.Component {
this.setState({
builds: response.data,
fetching: false,
currentPage: Math.floor(response.data.offset / this.state.resultsPerPage) + 1,
})
})
}
@ -231,9 +246,25 @@ class BuildsPage extends React.Component {
this.handleFilterChange(filters)
}
handlePerPageSelect = (event, perPage) => {
const { filters } = this.state
this.setState({ resultsPerPage: perPage })
const newFilters = { ...filters, limit: [perPage,] }
this.handleFilterChange(newFilters)
}
handleSetPage = (event, pageNumber) => {
const { filters, resultsPerPage } = this.state
this.setState({ currentPage: pageNumber })
let offset = resultsPerPage * (pageNumber - 1)
const newFilters = { ...filters, skip: [offset,] }
this.handleFilterChange(newFilters)
}
render() {
const { history } = this.props
const { builds, fetching, filters } = this.state
const { builds, fetching, filters, resultsPerPage, currentPage } = this.state
return (
<PageSection variant={PageSectionVariants.light}>
<FilterToolbar
@ -241,6 +272,14 @@ class BuildsPage extends React.Component {
onFilterChange={this.handleFilterChange}
filters={filters}
/>
<Pagination
itemCount={builds.total}
perPage={resultsPerPage}
page={currentPage}
widgetId="pagination-menu"
onPerPageSelect={this.handlePerPageSelect}
onSetPage={this.handleSetPage}
/>
<BuildTable
builds={builds}
fetching={fetching}

View File

@ -15,7 +15,7 @@
import * as React from 'react'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { PageSection, PageSectionVariants } from '@patternfly/react-core'
import { PageSection, PageSectionVariants, Pagination } from '@patternfly/react-core'
import { fetchBuildsets } from '../api'
import {
@ -82,12 +82,26 @@ class BuildsetsPage extends React.Component {
},
]
const _filters = getFiltersFromUrl(props.location, this.filterCategories)
const perPage = _filters.limit[0]
? parseInt(_filters.limit[0])
: 50
const currentPage = _filters.skip[0]
? Math.floor(parseInt(_filters.skip[0] / perPage)) + 1
: 1
this.state = {
buildsets: [],
buildsets: {
buildsets: [],
offset: null,
total: null,
},
fetching: false,
filters: getFiltersFromUrl(props.location, this.filterCategories),
filters: _filters,
projectsFetched: false,
pipelinesFetched: false,
resultsPerPage: perPage,
currentPage: currentPage,
}
}
@ -135,6 +149,7 @@ class BuildsetsPage extends React.Component {
this.setState({
buildsets: response.data,
fetching: false,
currentPage: Math.floor(response.data.offset / this.state.resultsPerPage) + 1,
})
}
)
@ -177,9 +192,24 @@ class BuildsetsPage extends React.Component {
this.handleFilterChange(filters)
}
handlePerPageSelect = (event, perPage) => {
const { filters } = this.state
this.setState({ resultsPerPage: perPage })
const newFilters = { ...filters, limit: [perPage,] }
this.handleFilterChange(newFilters)
}
handleSetPage = (event, pageNumber) => {
const { filters, resultsPerPage } = this.state
this.setState({ currentPage: pageNumber })
const offset = resultsPerPage * (pageNumber - 1)
const newFilters = { ...filters, skip: [offset,] }
this.handleFilterChange(newFilters)
}
render() {
const { history } = this.props
const { buildsets, fetching, filters } = this.state
const { buildsets, fetching, filters, resultsPerPage, currentPage } = this.state
return (
<PageSection variant={PageSectionVariants.light}>
<FilterToolbar
@ -187,6 +217,14 @@ class BuildsetsPage extends React.Component {
onFilterChange={this.handleFilterChange}
filters={filters}
/>
<Pagination
itemCount={buildsets.total}
perPage={resultsPerPage}
page={currentPage}
widgetId="pagination-menu"
onPerPageSelect={this.handlePerPageSelect}
onSetPage={this.handleSetPage}
/>
<BuildsetTable
buildsets={buildsets}
fetching={fetching}

View File

@ -111,14 +111,31 @@ class DatabaseSession(object):
q = self.listFilter(q, provides_table.c.name, provides)
q = self.listFilter(q, build_table.c.held, held)
try:
total = q.count()
except sqlalchemy.orm.exc.NoResultFound:
return {
'total': 0,
'offset': offset,
'builds': []
}
q = q.order_by(build_table.c.id.desc()).\
limit(limit).\
offset(offset)
try:
return q.all()
return {
'total': total,
'offset': offset,
'builds': q.all()
}
except sqlalchemy.orm.exc.NoResultFound:
return []
return {
'total': 0,
'offset': offset,
'builds': []
}
def getBuild(self, tenant, uuid):
build_table = self.connection.zuul_build_table
@ -177,14 +194,31 @@ class DatabaseSession(object):
elif complete is False:
q = q.filter(buildset_table.c.result == None) # noqa
try:
total = q.count()
except sqlalchemy.orm.exc.NoResultFound:
return {
'total': 0,
'offset': offset,
'buildsets': []
}
q = q.order_by(buildset_table.c.id.desc()).\
limit(limit).\
offset(offset)
try:
return q.all()
return {
'total': total,
'offset': offset,
'buildsets': q.all()
}
except sqlalchemy.orm.exc.NoResultFound:
return []
return {
'total': 0,
'offset': offset,
'buildsets': []
}
def getBuildset(self, tenant, uuid):
"""Get one buildset with its builds"""

View File

@ -2747,7 +2747,7 @@ class QueueItem(object):
if requirements_tuple not in self._cached_sql_results:
conn = self.pipeline.manager.sched.connections.getSqlConnection()
if conn:
builds = conn.getBuilds(
_builds = conn.getBuilds(
tenant=self.pipeline.tenant.name,
project=self.change.project.name,
pipeline=self.pipeline.name,
@ -2755,6 +2755,7 @@ class QueueItem(object):
branch=self.change.branch,
patchset=self.change.patchset,
provides=requirements_tuple)
builds = _builds['builds']
else:
builds = []
# Just look at the most recent buildset.

View File

@ -1004,7 +1004,12 @@ class ZuulWebAPI(object):
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return [self.buildToDict(b, b.buildset) for b in builds]
return {
'total': builds['total'],
'offset': builds['offset'],
'builds': [self.buildToDict(b, b.buildset)
for b in builds['builds']]
}
@cherrypy.expose
@cherrypy.tools.save_params()
@ -1013,9 +1018,11 @@ class ZuulWebAPI(object):
connection = self._get_connection()
data = connection.getBuilds(tenant=tenant, uuid=uuid, limit=1)
if not data:
if data['total'] == 0:
raise cherrypy.HTTPError(404, "Build not found")
data = self.buildToDict(data[0], data[0].buildset)
build = data['builds'][0]
data = self.buildToDict(build,
build.buildset)
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return data
@ -1049,10 +1056,10 @@ class ZuulWebAPI(object):
buildsets = connection.getBuildsets(
tenant=tenant, project=project, pipeline=pipeline,
branch=branch, complete=True, limit=1)
if not buildsets:
if buildsets['total'] == 0:
raise cherrypy.HTTPError(404, 'No buildset found')
if buildsets[0].result == 'SUCCESS':
if buildsets['buildsets'][0].result == 'SUCCESS':
file = 'passing.svg'
else:
file = 'failing.svg'
@ -1083,7 +1090,12 @@ class ZuulWebAPI(object):
resp = cherrypy.response
resp.headers['Access-Control-Allow-Origin'] = '*'
return [self.buildsetToDict(b) for b in buildsets]
return {
'total': buildsets['total'],
'offset': buildsets['offset'],
'buildsets': [self.buildsetToDict(b)
for b in buildsets['buildsets']]
}
@cherrypy.expose
@cherrypy.tools.save_params()