From 53cb5b6c031db4f08aa68a84b28e58d1c5a8dcb7 Mon Sep 17 00:00:00 2001 From: I515719 Date: Wed, 9 Sep 2026 14:09:44 +0800 Subject: [PATCH 1/2] feat(DM01-6255): add lightweight builds summary API for faster build list Replace the heavy /jobs/ endpoint (one row per job with full job details) with an enhanced /builds/ endpoint that returns one aggregated row per build. The new query computes build state server-side via bool_or() aggregation and only returns the fields needed for the build list view: state, start/end dates, commit branch/author, PR info. Frontend switches _loadJobs() and loadBuilds() to the new /builds/ endpoint and adds an addBuilds store mutation to populate Build objects from the lightweight response format. For a build with 20 jobs this reduces API response size by ~90%. --- src/api/handlers/build.py | 88 ++++++++++++++++++---- src/dashboard-client/src/models/Project.js | 12 +-- src/dashboard-client/src/store.js | 31 ++++++++ 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/src/api/handlers/build.py b/src/api/handlers/build.py index 011310fb7..a454e860e 100644 --- a/src/api/handlers/build.py +++ b/src/api/handlers/build.py @@ -19,10 +19,10 @@ @ns.route('/') @api.doc(responses={403: 'Not Authorized'}) class Builds(Resource): - @api.marshal_list_with(build_model) def get(self, project_id): ''' - Returns builds + Returns build summaries with aggregated state, dates, commit and PR info. + Accepts: from, to, sha, branch, cronjob, state, size/build_limit ''' build_from = request.args.get('from', None) @@ -30,7 +30,8 @@ def get(self, project_id): sha = request.args.get('sha', None) branch = request.args.get('branch', None) cronjob = request.args.get('cronjob', None) - size = request.args.get('size', 10) + state = request.args.get('state', None) + size = request.args.get('build_limit', request.args.get('size', 10)) if cronjob == "true": cronjob = True @@ -62,21 +63,49 @@ def get(self, project_id): if not build_from: build_from = 0 - #if build_to - build_from > 500: - # build_from = max(build_to - 500, 0) - - p = g.db.execute_many_dict(''' - SELECT b.id, b.build_number, b.restart_counter, b.is_cronjob + rows = g.db.execute_many_dict(''' + SELECT + b.id, + b.build_number, + b.restart_counter, + b.is_cronjob, + CASE + WHEN bool_or(j.state IN ('queued', 'scheduled', 'running') + AND NOT j.restarted) THEN 'running' + WHEN bool_or(j.state = 'killed' AND NOT j.restarted) THEN 'killed' + WHEN bool_or(j.state = 'error' AND NOT j.restarted) THEN 'error' + WHEN bool_or(j.state = 'failure' AND NOT j.restarted) THEN 'failure' + WHEN bool_or(j.state = 'unstable' AND NOT j.restarted) THEN 'unstable' + ELSE 'finished' + END AS state, + to_char(min(j.start_date), 'YYYY-MM-DD HH24:MI:SS') AS start_date, + to_char(max(j.end_date), 'YYYY-MM-DD HH24:MI:SS') AS end_date, + c.id AS commit_id, + c.branch AS commit_branch, + c.author_name AS commit_author_name, + c.tag AS commit_tag, + c.url AS commit_url, + su.filename AS source_upload_filename, + pr.title AS pull_request_title, + pr.url AS pull_request_url FROM build b - LEFT OUTER JOIN commit c - ON b.commit_id = c.id + LEFT JOIN job j ON j.build_id = b.id + LEFT JOIN commit c ON b.commit_id = c.id + LEFT JOIN source_upload su ON b.source_upload_id = su.id + LEFT JOIN pull_request pr ON c.pull_request_id = pr.id WHERE b.project_id = %(pid)s AND b.build_number < %(to)s AND b.build_number >= %(from)s - AND (%(sha)s IS NULL OR c.id = %(sha)s) - AND (%(branch)s IS NULL OR c.branch = %(branch)s) + AND (%(sha)s IS NULL OR c.id = %(sha)s) + AND (%(branch)s IS NULL OR c.branch = %(branch)s) AND (%(cronjob)s IS NULL OR b.is_cronjob = %(cronjob)s) - ORDER BY build_number DESC, restart_counter DESC + AND (%(state)s IS NULL OR EXISTS ( + SELECT 1 FROM job jf WHERE jf.build_id = b.id AND jf.state = %(state)s + )) + GROUP BY b.id, b.build_number, b.restart_counter, b.is_cronjob, + c.id, c.branch, c.author_name, c.tag, c.url, + su.filename, pr.title, pr.url + ORDER BY b.build_number DESC, b.restart_counter DESC LIMIT %(size)s ''', { 'pid': project_id, @@ -85,10 +114,41 @@ def get(self, project_id): 'sha': sha, 'branch': branch, 'cronjob': cronjob, + 'state': state, 'size': size, }) - return p + result = [] + for b in rows: + o = { + 'id': b['id'], + 'build_number': b['build_number'], + 'restart_counter': b['restart_counter'], + 'is_cronjob': b['is_cronjob'], + 'state': b['state'], + 'start_date': b['start_date'], + 'end_date': b['end_date'], + 'commit': None, + 'source_upload': None, + 'pull_request': None, + } + if b['commit_id']: + o['commit'] = { + 'id': b['commit_id'], + 'branch': b['commit_branch'], + 'author_name': b['commit_author_name'], + 'tag': b['commit_tag'], + 'url': b['commit_url'], + } + if b['source_upload_filename']: + o['source_upload'] = {'filename': b['source_upload_filename']} + if b['pull_request_title']: + o['pull_request'] = { + 'title': b['pull_request_title'], + 'url': b['pull_request_url'], + } + result.append(o) + return result @ns.route('/') @api.doc(responses={403: 'Not Authorized'}) diff --git a/src/dashboard-client/src/models/Project.js b/src/dashboard-client/src/models/Project.js index 02a3ec7b7..e1c1640f5 100644 --- a/src/dashboard-client/src/models/Project.js +++ b/src/dashboard-client/src/models/Project.js @@ -56,7 +56,7 @@ export default class Project { } loadBuilds (from, to, sha, branch, cronjob, buildLimit) { - let url = `projects/${this.id}/jobs/?from=${from}&to=${to}` + let url = `projects/${this.id}/builds/?from=${from}&to=${to}` if (sha) { url += `&sha=${sha}` @@ -75,8 +75,8 @@ export default class Project { } return NewAPIService.get(url) - .then((jobs) => { - this._addJobs(jobs) + .then((builds) => { + store.commit('addBuilds', { projectId: this.id, builds }) }) .catch((err) => { NotificationService.$emit('NOTIFICATION', new Notification(err)) @@ -178,9 +178,9 @@ export default class Project { } _loadJobs () { - return NewAPIService.get(`projects/${this.id}/jobs/`) - .then((response) => { - store.commit('addJobs', response) + return NewAPIService.get(`projects/${this.id}/builds/`) + .then((builds) => { + store.commit('addBuilds', { projectId: this.id, builds }) events.listenJobs(this) }) } diff --git a/src/dashboard-client/src/store.js b/src/dashboard-client/src/store.js index c0b8ba423..60407ef4c 100644 --- a/src/dashboard-client/src/store.js +++ b/src/dashboard-client/src/store.js @@ -170,6 +170,36 @@ function handleJobUpdate (state, event) { project._updateState() } +function addBuilds (state, payload) { + const project = findProject(state, payload.projectId) + if (!project) return + + for (const b of payload.builds) { + let build = findBuild(project, b.id) + if (!build) { + build = new Build(b.id, b.build_number, b.restart_counter, b.is_cronjob, + b.commit || null, b.pull_request || null, project) + let builds = [build] + for (let ex of project.builds) { + builds.push(ex) + } + builds = _(builds) + .chain() + .sortBy((x) => x.restartCounter) + .sortBy((x) => x.number) + .value() + .reverse() + project.builds = builds + } + build.state = b.state + build.startDate = b.start_date ? toDate(b.start_date) : null + build.endDate = b.end_date ? toDate(b.end_date) : null + if (b.commit) build.commit = b.commit + if (b.pull_request) build.pull_request = b.pull_request + } + project._updateState() +} + function addProjects (state, projects) { for (const project of projects) { let p = findProject(state, project.id) @@ -353,6 +383,7 @@ function setAdminGlobalTokens (state, tokens) { const mutations = { addProjects, addJobs, + addBuilds, setSecrets, setCronJobs, setSSHKeys, From 71a903a7052d6d0bf4d1e89c6f07ba1422460409 Mon Sep 17 00:00:00 2001 From: I515719 Date: Wed, 9 Sep 2026 14:26:16 +0800 Subject: [PATCH 2/2] fix(DM01-6255): address code review findings in builds summary API - build.py: switch LEFT JOIN job to INNER JOIN so builds with no jobs (newly triggered, not yet scheduled) are excluded rather than showing a false 'finished' state - build.py: replace NOT j.restarted with j.restarted IS NOT TRUE to safely handle nullable restarted column - build.py: replace EXISTS state filter with HAVING on computed state so ?state=running correctly matches builds where jobs are queued or scheduled (not just literally 'running') - store.js: guard project._updateState() with builds.length > 0 to avoid crash when addBuilds is called with an empty response - store.js: fix ESLint indentation in new Build() constructor call - Project.js: fix getBuild() to call _loadBuild() when build is cached but has no jobs loaded, preventing empty graph on build detail page --- src/api/handlers/build.py | 26 ++++++++++++++-------- src/dashboard-client/src/models/Project.js | 2 +- src/dashboard-client/src/store.js | 10 ++++++--- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/src/api/handlers/build.py b/src/api/handlers/build.py index a454e860e..7d12fce24 100644 --- a/src/api/handlers/build.py +++ b/src/api/handlers/build.py @@ -71,11 +71,11 @@ def get(self, project_id): b.is_cronjob, CASE WHEN bool_or(j.state IN ('queued', 'scheduled', 'running') - AND NOT j.restarted) THEN 'running' - WHEN bool_or(j.state = 'killed' AND NOT j.restarted) THEN 'killed' - WHEN bool_or(j.state = 'error' AND NOT j.restarted) THEN 'error' - WHEN bool_or(j.state = 'failure' AND NOT j.restarted) THEN 'failure' - WHEN bool_or(j.state = 'unstable' AND NOT j.restarted) THEN 'unstable' + AND j.restarted IS NOT TRUE) THEN 'running' + WHEN bool_or(j.state = 'killed' AND j.restarted IS NOT TRUE) THEN 'killed' + WHEN bool_or(j.state = 'error' AND j.restarted IS NOT TRUE) THEN 'error' + WHEN bool_or(j.state = 'failure' AND j.restarted IS NOT TRUE) THEN 'failure' + WHEN bool_or(j.state = 'unstable' AND j.restarted IS NOT TRUE) THEN 'unstable' ELSE 'finished' END AS state, to_char(min(j.start_date), 'YYYY-MM-DD HH24:MI:SS') AS start_date, @@ -89,7 +89,7 @@ def get(self, project_id): pr.title AS pull_request_title, pr.url AS pull_request_url FROM build b - LEFT JOIN job j ON j.build_id = b.id + INNER JOIN job j ON j.build_id = b.id LEFT JOIN commit c ON b.commit_id = c.id LEFT JOIN source_upload su ON b.source_upload_id = su.id LEFT JOIN pull_request pr ON c.pull_request_id = pr.id @@ -99,12 +99,20 @@ def get(self, project_id): AND (%(sha)s IS NULL OR c.id = %(sha)s) AND (%(branch)s IS NULL OR c.branch = %(branch)s) AND (%(cronjob)s IS NULL OR b.is_cronjob = %(cronjob)s) - AND (%(state)s IS NULL OR EXISTS ( - SELECT 1 FROM job jf WHERE jf.build_id = b.id AND jf.state = %(state)s - )) GROUP BY b.id, b.build_number, b.restart_counter, b.is_cronjob, c.id, c.branch, c.author_name, c.tag, c.url, su.filename, pr.title, pr.url + HAVING (%(state)s IS NULL OR + CASE + WHEN bool_or(j.state IN ('queued', 'scheduled', 'running') + AND j.restarted IS NOT TRUE) THEN 'running' + WHEN bool_or(j.state = 'killed' AND j.restarted IS NOT TRUE) THEN 'killed' + WHEN bool_or(j.state = 'error' AND j.restarted IS NOT TRUE) THEN 'error' + WHEN bool_or(j.state = 'failure' AND j.restarted IS NOT TRUE) THEN 'failure' + WHEN bool_or(j.state = 'unstable' AND j.restarted IS NOT TRUE) THEN 'unstable' + ELSE 'finished' + END = %(state)s + ) ORDER BY b.build_number DESC, b.restart_counter DESC LIMIT %(size)s ''', { diff --git a/src/dashboard-client/src/models/Project.js b/src/dashboard-client/src/models/Project.js index e1c1640f5..ce5e2de39 100644 --- a/src/dashboard-client/src/models/Project.js +++ b/src/dashboard-client/src/models/Project.js @@ -86,7 +86,7 @@ export default class Project { getBuild (number, restartCounter) { const b = this._getBuild(number, restartCounter) - if (b) { + if (b && b.jobs.length > 0) { return new Promise((resolve) => { resolve(b) }) } diff --git a/src/dashboard-client/src/store.js b/src/dashboard-client/src/store.js index 60407ef4c..6ebcdb4a0 100644 --- a/src/dashboard-client/src/store.js +++ b/src/dashboard-client/src/store.js @@ -177,8 +177,10 @@ function addBuilds (state, payload) { for (const b of payload.builds) { let build = findBuild(project, b.id) if (!build) { - build = new Build(b.id, b.build_number, b.restart_counter, b.is_cronjob, - b.commit || null, b.pull_request || null, project) + build = new Build( + b.id, b.build_number, b.restart_counter, b.is_cronjob, + b.commit || null, b.pull_request || null, project + ) let builds = [build] for (let ex of project.builds) { builds.push(ex) @@ -197,7 +199,9 @@ function addBuilds (state, payload) { if (b.commit) build.commit = b.commit if (b.pull_request) build.pull_request = b.pull_request } - project._updateState() + if (project.builds.length > 0) { + project._updateState() + } } function addProjects (state, projects) {