mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 23:45:44 +07:00
[IMP] runbot: allow multiple bundles in stats
Adds support for multiple bundles at the same time in the stats page. Support is pretty basic and visual does not differentiate between builds from one bundle or another.
This commit is contained in:
parent
4eaf55dc58
commit
47fcb9dcb3
@ -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/<model("runbot.project"):project>',
|
||||
@ -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/<model("runbot.project"):project>',
|
||||
'/runbot/bundles_json/<model("runbot.project"):project>/search/<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
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -4,8 +4,32 @@
|
||||
<div class="container mb-4">
|
||||
<div class="row mt-2 g-1">
|
||||
<div class="col-12">
|
||||
<label for="bundle" class="form-label fw-bold mb-0">Bundle</label>
|
||||
<input id="bundle" class="form-control" disabled="" t-att-value="props.bundle.name" aria-label="Bundle"/>
|
||||
<label for="bundle" class="form-label fw-bold mb-0">Bundles</label>
|
||||
<div class="form-control p-0">
|
||||
<div class="m-2 d-flex flex-wrap gap-1">
|
||||
<span t-foreach="bundles" t-as="bundle" t-key="bundle.id"
|
||||
class="badge rounded-pill" t-attf-style="background-color: {{bundleColor(bundle)}};"
|
||||
>
|
||||
<t t-out="bundle.name"/>
|
||||
<t t-if="bundle.id !== props.bundle.id">
|
||||
<span class="ms-2 badge rounded-pill px-1" role="button" t-on-click="() => this.onClickRemoveBundle(bundle)">
|
||||
<i role="button" class="fa fa-times"/>
|
||||
</span>
|
||||
</t>
|
||||
</span>
|
||||
</div>
|
||||
<input id="bundle" class="form-control border-0" aria-label="Bundle" t-model="state.search"/>
|
||||
<div>
|
||||
<t t-set="_searchBundles" t-value="searchBundles"/>
|
||||
<ul t-if="_searchBundles.length || state.searchLoading" class="dropdown-menu show">
|
||||
<li><a class="dropdown-item fw-bold" t-on-click="() => this.state.search = ''">Clear search</a></li>
|
||||
<li t-if="state.searchLoading" class="text-center"><i class="fa fa-circle-o-notch fa-spin"/></li>
|
||||
<li t-if="_searchBundles.length" t-foreach="_searchBundles" t-as="bundle" t-key="bundle.id">
|
||||
<a class="dropdown-item" t-out="bundle.name" t-on-click="() => this.onClickAddBundle(bundle)"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="trigger" class="form-label fw-bold mb-0">Trigger</label>
|
||||
|
@ -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,
|
||||
|
@ -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(',');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -69,6 +69,7 @@
|
||||
__runbot_stats_values = <t t-out="json.dumps({
|
||||
'bundle': {'id': bundle.id, 'name': bundle.name},
|
||||
'trigger': {'id': trigger.id, 'name': trigger.name},
|
||||
'project': {'id': project.id, 'name': project.name},
|
||||
'stats_categories': stats_categories,
|
||||
'triggers_by_category': triggers_by_category,
|
||||
})"/>;
|
||||
|
Loading…
Reference in New Issue
Block a user