diff --git a/runbot/controllers/frontend.py b/runbot/controllers/frontend.py index a7551cf0..25ca1f62 100644 --- a/runbot/controllers/frontend.py +++ b/runbot/controllers/frontend.py @@ -104,6 +104,48 @@ class Runbot(Controller): response.set_cookie(key, '-'.join(enabled_triggers)) return response + def _get_bundles(self, /, project, search='', has_pr=None, for_next_freeze=False, limit=40, **_): + domain = [('last_batch', '!=', False), ('project_id', '=', project.id)] + if not search: + domain.append(('no_build', '=', False)) + + if has_pr is not None: + domain.append(('has_pr', '=', bool(has_pr))) + + filter_mode = request.httprequest.cookies.get('filter_mode', False) + if filter_mode == 'sticky': + domain.append(('sticky', '=', True)) + elif filter_mode == 'nosticky': + domain.append(('sticky', '=', False)) + + if for_next_freeze: + domain.append(('for_next_freeze', '=', True)) + + if search: + search_domains = [] + pr_numbers = [] + for search_elem in search.split("|"): + if search_elem.isnumeric(): + pr_numbers.append(int(search_elem)) + operator = '=ilike' if '%' in search_elem else 'ilike' + search_domains.append([('name', operator, search_elem)]) + if pr_numbers: + res = request.env['runbot.branch'].search([('name', 'in', pr_numbers)]) + if res: + search_domains.append([('id', 'in', res.mapped('bundle_id').ids)]) + search_domain = expression.OR(search_domains) + domain = expression.AND([domain, search_domain]) + + e = expression.expression(domain, request.env['runbot.bundle']) + query = e.query + query.order = """ + (case when "runbot_bundle".sticky then 1 when "runbot_bundle".sticky is null then 2 else 2 end), + case when "runbot_bundle".sticky then "runbot_bundle".version_number end collate "C" desc, + "runbot_bundle".last_batch desc + """ + query.limit = min(int(limit), 200) + return request.env['runbot.bundle'].browse(query) + @route(['/', '/runbot', '/runbot/', @@ -127,46 +169,7 @@ class Runbot(Controller): 'hosts_data': request.env['runbot.host'].search([('assigned_only', '=', False)]), } if project: - domain = [('last_batch', '!=', False), ('project_id', '=', project.id)] - if not search: - domain.append(('no_build', '=', False)) - - if has_pr is not None: - domain.append(('has_pr', '=', bool(has_pr))) - - filter_mode = request.httprequest.cookies.get('filter_mode', False) - if filter_mode == 'sticky': - domain.append(('sticky', '=', True)) - elif filter_mode == 'nosticky': - domain.append(('sticky', '=', False)) - - if for_next_freeze: - domain.append(('for_next_freeze', '=', True)) - - if search: - search_domains = [] - pr_numbers = [] - for search_elem in search.split("|"): - if search_elem.isnumeric(): - pr_numbers.append(int(search_elem)) - operator = '=ilike' if '%' in search_elem else 'ilike' - search_domains.append([('name', operator, search_elem)]) - if pr_numbers: - res = request.env['runbot.branch'].search([('name', 'in', pr_numbers)]) - if res: - search_domains.append([('id', 'in', res.mapped('bundle_id').ids)]) - search_domain = expression.OR(search_domains) - domain = expression.AND([domain, search_domain]) - - e = expression.expression(domain, request.env['runbot.bundle']) - query = e.query - query.order = """ - (case when "runbot_bundle".sticky then 1 when "runbot_bundle".sticky is null then 2 else 2 end), - case when "runbot_bundle".sticky then "runbot_bundle".version_number end collate "C" desc, - "runbot_bundle".last_batch desc - """ - query.limit = min(int(limit), 200) - bundles = env['runbot.bundle'].browse(query) + bundles = self._get_bundles(project=project, search=search, has_pr=has_pr, for_next_freeze=for_next_freeze, limit=limit) category_id = int(request.httprequest.cookies.get('category') or 0) or request.env['ir.model.data']._xmlid_to_res_id('runbot.default_category') @@ -558,9 +561,18 @@ class Runbot(Controller): } return request.render("runbot.build_stats", context) + @route([ + '/runbot/bundles_json', + '/runbot/bundles_json/', + '/runbot/bundles_json//search/'], type='http', auth='public', website=False, sitemap=False) + def bundles_json(self, project=None, projects=False, **kwargs): + if not project and projects: + project = projects[0] + bundles = self._get_bundles(project=project, **kwargs) + return request.make_json_response(bundles.read(['id', 'name'])) @route(['/runbot/stats/'], type='json', auth="public", website=False, sitemap=False) - def stats_json(self, bundle_id=False, trigger_id=False, key_category='', center_build_id=False, ok_only=False, limit=100, search=None, **post): + def stats_json(self, bundle_id=False, trigger_id=False, key_category='', center_build_id=False, ok_only=False, limit=100, search=None, add_bundles='', **post): """ Json stats """ trigger_id = trigger_id and int(trigger_id) bundle_id = bundle_id and int(bundle_id) @@ -572,9 +584,15 @@ class Runbot(Controller): if not trigger_id or not bundle_id or not trigger.exists() or not bundle.exists(): return request.not_found() + bundle_ids = [bundle_id] + for bundle_id in add_bundles.split(','): + if not bundle_id.isdigit(): + continue + bundle_ids.append(int(bundle_id)) + builds_domain = [ ('global_state', 'in', ('running', 'done')), - ('slot_ids.batch_id.bundle_id', '=', bundle_id), + ('slot_ids.batch_id.bundle_id', 'in', bundle_ids), ('params_id.trigger_id', '=', trigger.id), ] if ok_only: @@ -640,6 +658,7 @@ class Runbot(Controller): 'stats_categories': categories, 'bundle': bundle, 'trigger': trigger, + 'project': bundle.project_id, # Category name -> List of trigger name + id 'triggers_by_category': triggers_by_category } diff --git a/runbot/static/src/stats/stats_config.js b/runbot/static/src/stats/stats_config.js index bbe2d70e..006bfc40 100644 --- a/runbot/static/src/stats/stats_config.js +++ b/runbot/static/src/stats/stats_config.js @@ -1,10 +1,38 @@ /** @odoo-module **/ -import { Component } from '@odoo/owl'; +import { Component, useEffect, useState } from '@odoo/owl'; import { useConfig } from '@runbot/stats/use_config'; +import { debounce, randomColor } from '@runbot/utils'; +/** + * @typedef Bundle + * + * @property {Number} id + * @property {String} name + */ + +const bundleNameCache = {}; // id to name + +/** + * Returns the name of the bundle according to the cache. + * + * @param {Number} bundleId id of the bundle + */ +const getBundleName = (bundleId) => { + return bundleNameCache[bundleId] || bundleId.toString(); +} + +/** + * Populates the cache when a new list of bundle is loaded. + * + * @param {Bundle[]} bundles list of bundles + */ +const populateCache = (bundles) => { + bundles.forEach(({ id, name }) => bundleNameCache[id] = name); +} + export class StatsConfig extends Component { static template = 'runbot.StatsConfig'; static props = { @@ -22,6 +50,13 @@ export class StatsConfig extends Component { name: { type: String }, }, }, + project: { + type: Object, + shape: { + id: { type: Number }, + name: { type: String }, + }, + }, stats_categories: { type: Array, element: String }, triggers_by_category: { type: Object, @@ -40,7 +75,91 @@ export class StatsConfig extends Component { }; setup() { + this._origFetchBundles = this._fetchBundles.bind(this); + this._fetchBundles = debounce(this._fetchBundles.bind(this), 500); this.config = useConfig(); + this.requestId = 0; + + this.state = useState({ + search: '', + searchLoading: false, + searchResult: [], + }); + + useEffect( + () => this.fetchBundles(), + () => [this.state.search], + ); + } + + /** + * List of active bundles + */ + get bundles() { + return [ + this.props.bundle, + ...this.config.getBundles().map( + (id) => ({ id, name: getBundleName(id) }), + ), + ]; + } + + /** + * List of search bundles without the active bundles. + */ + get searchBundles() { + const activeBundles = this.bundles; + return this.state.searchResult.filter( + (sr) => !activeBundles.find(b => b.id === sr.id) + ); + } + + /** + * Gets a color for the given bundle. + * + * @param {Bundle} bundle the bundle + * @returns {String} color as hexcode + */ + bundleColor(bundle) { + return randomColor(bundle.name); + } + + /** + * Fetches the bundle according to the current search state. + * If the search is emtpy the result is changed directly, otherwise a debounce + * happend between this call and the actual result being shown on screen, + * however this function is responsible for toggling the visual loading state. + */ + fetchBundles() { + this.state.searchLoading = true; + if (this.state.search.trim() === '') { + this._origFetchBundles(); + } else { + this._fetchBundles(); + } + } + + /** + * Fetches bundles from the backend according to the search state. + * If the search is empty, the state is reset, the lookup result is not kept if this is not the latest call + * to this method. + * Regardless if bundles are kept from the search or not the result is used to populate the name cache. + */ + async _fetchBundles() { + const requestId = ++this.requestId; + const search = this.state.search.trim(); + if (!search.length) { + this.state.searchLoading = false; + this.state.searchResult = []; + return; + } + const result = await fetch(`/runbot/bundles_json/${this.props.project.id}/search/${search}/?limit=10`); + const resultJson = await result.json(); + populateCache(resultJson); + if (requestId === this.requestId) { + this.state.searchLoading = false; + this.state.searchResult = resultJson; + } } /** @@ -59,4 +178,22 @@ export class StatsConfig extends Component { ); window.location.href = `${origin}/runbot/stats/${bundle}/${event.target.value}${search}#${newParams.toString()}`; } + + /** + * Called when adding a bundle to the list of bundles to include. + * + * @param {Bundle} bundle the bundle + */ + onClickAddBundle(bundle) { + this.config.toggleBundle(bundle.id); + } + + /** + * Called when removing a bundle from the list of bundles to include. + * + * @param {Bundle} bundle the bundle + */ + onClickRemoveBundle(bundle) { + this.config.toggleBundle(bundle.id); + } }; diff --git a/runbot/static/src/stats/stats_config.xml b/runbot/static/src/stats/stats_config.xml index 555799e8..d1a84f9f 100644 --- a/runbot/static/src/stats/stats_config.xml +++ b/runbot/static/src/stats/stats_config.xml @@ -4,8 +4,32 @@
- - + +
+
+ + + + + + + + +
+ + +
diff --git a/runbot/static/src/stats/stats_root.js b/runbot/static/src/stats/stats_root.js index 6fb28ebe..7698a2d0 100644 --- a/runbot/static/src/stats/stats_root.js +++ b/runbot/static/src/stats/stats_root.js @@ -27,6 +27,13 @@ export class StatsRoot extends Component { name: { type: String }, }, }, + project: { + type: Object, + shape: { + id: { type: Number }, + name: { type: String }, + }, + }, stats_categories: { type: Array, element: String }, triggers_by_category: { type: Object, diff --git a/runbot/static/src/stats/use_config.js b/runbot/static/src/stats/use_config.js index 3abeaf6c..1cc0f89a 100644 --- a/runbot/static/src/stats/use_config.js +++ b/runbot/static/src/stats/use_config.js @@ -10,6 +10,7 @@ export class Config { constructor({ limit = 25, center_build_id = '0', key_category = 'module_loading_queries', mode = 'normal', nb_dataset = 20, display_aggregate = 'none', visible_keys = '', + add_bundles = '', }) { this.limit = limit; this.center_build_id = center_build_id; @@ -18,6 +19,7 @@ export class Config { this.nb_dataset = nb_dataset; this.display_aggregate = display_aggregate; this.visible_keys = visible_keys; + this.add_bundles = add_bundles; // comma-separated list of bundle ids } /** @@ -66,7 +68,7 @@ export class Config { */ getRefetchKeys() { return [ - 'limit', 'center_build_id', 'key_category', + 'limit', 'center_build_id', 'key_category', 'add_bundles', ]; } @@ -121,6 +123,31 @@ export class Config { } this.pushVisibleKeys(keys); } + + /** + * Gets the bundle ids to add to the stat request. + * + * @returns {Number[]} list of bundle ids + */ + getBundles() { + return this.add_bundles.split(',').filter(s => s.length).map(Number); + } + + /** + * Toggles a bundle within the search. + * + * @param {Number} bundleId id of the bundle + */ + toggleBundle(bundleId) { + const bundles = this.getBundles(); + const bundleIdx = bundles.indexOf(bundleId); + if (bundleIdx === -1) { + bundles.push(bundleId); + } else { + bundles.splice(bundleIdx, 1); + } + this.add_bundles = bundles.join(','); + } } /** diff --git a/runbot/templates/build_stats.xml b/runbot/templates/build_stats.xml index a3a784fd..71bde452 100644 --- a/runbot/templates/build_stats.xml +++ b/runbot/templates/build_stats.xml @@ -69,6 +69,7 @@ __runbot_stats_values = ;