mirror of
https://github.com/odoo/runbot.git
synced 2025-03-15 23:45:44 +07:00
[REF] runbot: rewrite stats page using owl
Removing very old javascript and using new tools will allow us to more efficiently change and maintain the stat page in the futur. This commit is meant to produce a 1:1 copy of the stats page like it was before. Further refactoring will happen to improve performance, layout and component logic.
This commit is contained in:
parent
86810df3f7
commit
e43827a99d
@ -65,6 +65,20 @@
|
||||
'runbot/static/src/libs/diff_match_patch/diff_match_patch.js',
|
||||
'runbot/static/src/js/fields/*',
|
||||
],
|
||||
'runbot.assets_stats': [
|
||||
# Required for module loading
|
||||
('include', 'web.assets_frontend_minimal'),
|
||||
# Required for separate js and xml files
|
||||
'/web/static/src/core/template_inheritance.js',
|
||||
'/web/static/src/core/templates.js', # ^
|
||||
# Owl
|
||||
'web/static/lib/owl/owl.js',
|
||||
'web/static/lib/owl/odoo_module.js',
|
||||
# Runbot
|
||||
'/runbot/static/src/utils.js',
|
||||
'/runbot/static/src/chartjs_module.js',
|
||||
'/runbot/static/src/stats/**/*',
|
||||
],
|
||||
'runbot.assets_frontend': [
|
||||
'/web/static/lib/bootstrap/dist/css/bootstrap.css',
|
||||
'/web/static/src/libs/fontawesome/css/font-awesome.css',
|
||||
|
5
runbot/static/src/chartjs_module.js
Normal file
5
runbot/static/src/chartjs_module.js
Normal file
@ -0,0 +1,5 @@
|
||||
odoo.define("@runbot/chartjs", [], function () {
|
||||
"use strict";
|
||||
|
||||
return Chart;
|
||||
});
|
@ -352,35 +352,6 @@ body, .table {
|
||||
margin-left: -1px;
|
||||
}*/
|
||||
|
||||
.chart-legend {
|
||||
max-height: calc(100vh - 160px);
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.chart-legend .label {
|
||||
margin-left: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chart-legend .disabled .color {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.chart-legend .disabled .label {
|
||||
font-weight: normal;
|
||||
text-decoration: line-through;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.chart-legend ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.limited-height {
|
||||
max-height: 180px;
|
||||
overflow: scroll;
|
||||
|
@ -1,298 +0,0 @@
|
||||
|
||||
var config = {
|
||||
type: 'line',
|
||||
options: {
|
||||
|
||||
animation: {
|
||||
duration: 0
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
tooltips: {
|
||||
mode: 'point'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Builds'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Value'
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var shifted = false;
|
||||
$(document).on('keyup keydown', function(e){shifted = e.shiftKey} );
|
||||
|
||||
config.options.onClick = function(event, activeElements) {
|
||||
if (activeElements.length === 0){
|
||||
return
|
||||
}
|
||||
const build_id = config.data.labels[activeElements[0].index];
|
||||
if (shifted){
|
||||
config.searchParams['center_build_id'] = build_id;
|
||||
fetchUpdateChart();
|
||||
} else {
|
||||
window.open('/runbot/build/stats/' + build_id);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
function fetch(path, data, then) {
|
||||
const xhttp = new XMLHttpRequest();
|
||||
xhttp.onreadystatechange = function() {
|
||||
if (this.readyState == 4 && this.status == 200) {
|
||||
const res = JSON.parse(this.responseText);
|
||||
then(res.result);
|
||||
}
|
||||
};
|
||||
xhttp.open("POST", path);
|
||||
xhttp.setRequestHeader('Content-Type', 'application/json');
|
||||
xhttp.send(JSON.stringify({params:data}));
|
||||
};
|
||||
|
||||
function random_color(name){
|
||||
var colors = ['#004acd', '#3658c3', '#4a66ba', '#5974b2', '#6581aa', '#6f8fa3', '#7a9c9d', '#85a899', '#91b596', '#a0c096', '#fdaf56', '#f89a59', '#f1865a', '#e87359', '#dc6158', '#ce5055', '#bf4150', '#ad344b', '#992a45', '#84243d'];
|
||||
var sum = 0;
|
||||
for (var i = 0; i < name.length; i++) {
|
||||
sum += name.charCodeAt(i);
|
||||
}
|
||||
sum = sum % colors.length;
|
||||
color = colors[sum];
|
||||
|
||||
return color
|
||||
};
|
||||
|
||||
|
||||
function process_chart_data(){
|
||||
if (! config.result || Object.keys(config.result).length == 0)
|
||||
{
|
||||
config.data = {
|
||||
labels:[],
|
||||
datasets: [],
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var aggregate = document.getElementById('display_aggregate_selector').value;
|
||||
var aggregates = {};
|
||||
|
||||
|
||||
var builds = Object.keys(config.result);
|
||||
var newer_build_stats = config.result[builds.slice(-1)[0]];
|
||||
var older_build_stats = config.result[builds[0]];
|
||||
var keys = Object.keys(newer_build_stats) ;
|
||||
if (aggregate != 'sum') {
|
||||
keys.splice(keys.indexOf('Aggregate Sum'), 1);
|
||||
}
|
||||
if (aggregate != 'average') {
|
||||
keys.splice(keys.indexOf('Aggregate Average'), 1);
|
||||
}
|
||||
var mode = document.getElementById('mode_selector').value;
|
||||
|
||||
var sort_values = {}
|
||||
for (key of keys) {
|
||||
sort_value = NaN
|
||||
if (mode == 'normal') {
|
||||
sort_value = newer_build_stats[key]
|
||||
} else if (mode == 'alpha') {
|
||||
sort_value = key
|
||||
} else if (mode == 'change_count') {
|
||||
sort_value = 0
|
||||
previous = undefined
|
||||
for (build of builds) {
|
||||
res = config.result[build]
|
||||
value = res[key]
|
||||
if (previous !== undefined && value !== undefined && previous != value) {
|
||||
sort_value +=1
|
||||
}
|
||||
previous = value
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (mode == "difference") {
|
||||
var previous_value = 0;
|
||||
if (older_build_stats[key] !== undefined) {
|
||||
previous_value = older_build_stats[key]
|
||||
}
|
||||
sort_value = Math.abs(newer_build_stats[key] - previous_value)
|
||||
}
|
||||
}
|
||||
sort_values[key] = sort_value
|
||||
}
|
||||
keys.sort((m1, m2) => sort_values[m2] - sort_values[m1]);
|
||||
|
||||
if (config.searchParams.nb_dataset != -1) {
|
||||
visible_keys = new Set(keys.slice(0, config.searchParams.nb_dataset));
|
||||
} else {
|
||||
visible_keys = new Set(config.searchParams.visible_keys.split('-'))
|
||||
}
|
||||
console.log(visible_keys);
|
||||
function display_value(key, build_stats){
|
||||
if (build_stats[key] === undefined)
|
||||
return NaN;
|
||||
if (mode == 'normal' || mode == 'alpha')
|
||||
return build_stats[key]
|
||||
var previous_value = 0;
|
||||
if (older_build_stats[key] !== undefined) {
|
||||
previous_value = older_build_stats[key]
|
||||
}
|
||||
return build_stats[key] - previous_value
|
||||
}
|
||||
|
||||
config.data = {
|
||||
labels: builds,
|
||||
datasets: keys.map(function (key){
|
||||
return {
|
||||
label: key,
|
||||
data: builds.map(build => display_value(key, config.result[build])),
|
||||
borderColor: random_color(key),
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
lineTension: 0,
|
||||
hidden: !visible_keys.has(key),
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function fetchUpdateChart() {
|
||||
var chart_spinner = document.getElementById('chart_spinner');
|
||||
chart_spinner.style.visibility = 'visible';
|
||||
fetch_params = compute_fetch_params();
|
||||
console.log('fetch')
|
||||
fetch('/runbot/stats/', fetch_params, function(result) {
|
||||
config.result = result;
|
||||
Object.values(config.result).forEach(v => v['Aggregate Sum'] = Object.values(v).reduce((a, b) => a + b, 0))
|
||||
Object.values(config.result).forEach(v => v['Aggregate Average'] = Object.values(v).reduce((a, b) => a + b, 0)/Object.values(v).length)
|
||||
chart_spinner.style.visibility = 'hidden';
|
||||
updateChart()
|
||||
});
|
||||
}
|
||||
|
||||
function generateLegend() {
|
||||
var legend = $("<ul></ul>");
|
||||
for (data of config.data.datasets) {
|
||||
var legendElement = $(`<li><span class="color" style="border: 2px solid ${data.borderColor};"></span><span class="label" title="${data.label}">${data.label}<span></li>`)
|
||||
if (data.hidden){
|
||||
legendElement.addClass('disabled')
|
||||
}
|
||||
legend.append(legendElement)
|
||||
}
|
||||
$("#js-legend").html(legend);
|
||||
$("#js-legend > ul > li").on("click",function(e){
|
||||
var index = $(this).index();
|
||||
//$(this).toggleClass("disabled")
|
||||
var curr = window.statsChart.data.datasets[index];
|
||||
curr.hidden = !curr.hidden;
|
||||
config.searchParams.nb_dataset=-1;
|
||||
config.searchParams.visible_keys = window.statsChart.data.datasets.filter(dataset => !dataset.hidden).map(dataset => dataset.label).join('-')
|
||||
updateChart();
|
||||
})
|
||||
}
|
||||
|
||||
function updateForm() {
|
||||
for([key, value] of Object.entries(config.searchParams)){
|
||||
var selector = document.getElementById(key + '_selector');
|
||||
if (selector != null){
|
||||
selector.value = value;
|
||||
selector.onchange = function(){
|
||||
var id = this.id.replace('_selector', '');
|
||||
config.searchParams[this.id.replace('_selector', '')] = this.value;
|
||||
if (localParams.indexOf(id) == -1){
|
||||
fetchUpdateChart();
|
||||
} else {
|
||||
updateChart()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let display_forward = config.result && config.searchParams.center_build_id != 0 && (config.searchParams.center_build_id !== Object.keys(config.result).slice(-1)[0])
|
||||
document.getElementById("forward_button").style.visibility = display_forward ? "visible":"hidden";
|
||||
document.getElementById("fast_forward_button").style.visibility = display_forward ? "visible":"hidden";
|
||||
let display_backward = config.result && (config.searchParams.center_build_id !== Object.keys(config.result)[0])
|
||||
document.getElementById("backward_button").style.visibility = display_backward ? "visible":"hidden";
|
||||
}
|
||||
|
||||
function updateChart(){
|
||||
updateForm()
|
||||
updateUrl();
|
||||
process_chart_data();
|
||||
if (! window.statsChart) {
|
||||
var ctx = document.getElementById('canvas').getContext('2d');
|
||||
window.statsChart = new Chart(ctx, config);
|
||||
} else {
|
||||
window.statsChart.update();
|
||||
}
|
||||
generateLegend();
|
||||
}
|
||||
|
||||
function compute_fetch_params(){
|
||||
return {
|
||||
...config.searchParams,
|
||||
bundle_id: document.getElementById('bundle_id').value,
|
||||
trigger_id: document.getElementById('trigger_id').value,
|
||||
}
|
||||
};
|
||||
|
||||
function updateUrl(){
|
||||
window.location.hash = new URLSearchParams(config.searchParams).toString();
|
||||
}
|
||||
|
||||
async function waitForChart() {
|
||||
|
||||
function loop(resolve) {
|
||||
if (window.Chart) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(loop.bind(null, resolve),10);
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
loop(resolve);
|
||||
})
|
||||
}
|
||||
|
||||
window.onload = function() {
|
||||
config.searchParams = {
|
||||
limit: 25,
|
||||
center_build_id: 0,
|
||||
key_category: 'module_loading_queries',
|
||||
mode: 'normal',
|
||||
nb_dataset: 20,
|
||||
display_aggregate: 'none',
|
||||
visible_keys: '',
|
||||
};
|
||||
localParams = ['display_aggregate', 'mode', 'nb_dataset', 'visible_keys']
|
||||
|
||||
for([key, value] of new URLSearchParams(window.location.hash.replace("#","?"))){
|
||||
config.searchParams[key] = value;
|
||||
}
|
||||
|
||||
document.getElementById('backward_button').onclick = function(){
|
||||
config.searchParams['center_build_id'] = Object.keys(config.result)[0];
|
||||
fetchUpdateChart();
|
||||
}
|
||||
document.getElementById('forward_button').onclick = function(){
|
||||
config.searchParams['center_build_id'] = Object.keys(config.result).slice(-1)[0];
|
||||
fetchUpdateChart();
|
||||
}
|
||||
document.getElementById('fast_forward_button').onclick = function(){
|
||||
config.searchParams['center_build_id'] = 0;
|
||||
fetchUpdateChart();
|
||||
}
|
||||
|
||||
waitForChart().then(fetchUpdateChart);
|
||||
};
|
34
runbot/static/src/stats/stats.scss
Normal file
34
runbot/static/src/stats/stats.scss
Normal file
@ -0,0 +1,34 @@
|
||||
#wrapwrap:empty {
|
||||
content: 'This page required javascript to work';
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
max-height: calc(100vh - 160px);
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
cursor: pointer;
|
||||
padding: 5px;
|
||||
|
||||
.label {
|
||||
margin-left: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
.color {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: normal;
|
||||
text-decoration: line-through;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
298
runbot/static/src/stats/stats_chart.js
Normal file
298
runbot/static/src/stats/stats_chart.js
Normal file
@ -0,0 +1,298 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useEffect, useRef, useState } from '@odoo/owl';
|
||||
|
||||
import { debounce, filterKeys, randomColor } from '@runbot/utils';
|
||||
import { useBus } from '@runbot/stats/use_bus';
|
||||
import { useConfig, onConfigChange } from '@runbot/stats/use_config';
|
||||
import { Chart } from '@runbot/chartjs';
|
||||
|
||||
|
||||
export class StatsChart extends Component {
|
||||
static template = 'runbot.StatsChart';
|
||||
static props = {
|
||||
bundle_id: { type: Number },
|
||||
trigger_id: { type: Number },
|
||||
}
|
||||
|
||||
setup() {
|
||||
this._fetchStats = debounce(this._fetchStats.bind(this));
|
||||
this.config = useConfig();
|
||||
this.canvas = useRef('canvas');
|
||||
this.state = useState({
|
||||
data: {},
|
||||
});
|
||||
this.chartConfig = useState({
|
||||
type: 'line',
|
||||
options: {
|
||||
animation: {
|
||||
duration: 0,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
tooltips: {
|
||||
mode: 'point',
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Builds',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
scaleLabel: {
|
||||
display: true,
|
||||
labelString: 'Value',
|
||||
},
|
||||
},
|
||||
},
|
||||
onClick: (event, activeElements) => {
|
||||
const { native: { shiftKey }} = event;
|
||||
if (activeElements.length === 0) {
|
||||
return;
|
||||
}
|
||||
const build_id = this.chartConfig.data.labels[activeElements[0].index];
|
||||
if (shiftKey) {
|
||||
this.config.center_build_id = build_id;
|
||||
} else {
|
||||
window.open(`/runbot/build/stats/${build_id}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
onConfigChange(() => this.fetchStats(), true);
|
||||
useBus(this.env.bus, 'click-previous', () => this.selectPrevious());
|
||||
useBus(this.env.bus, 'click-next', () => this.selectNext());
|
||||
useEffect(() => {
|
||||
this.updateChart();
|
||||
}, () => [
|
||||
this.canvas, this.state.data,
|
||||
...Object.values(filterKeys(this.config, this.config.getChartUpdateKeys()))
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called before actually fetching stat, this triggers the spinner while waiting
|
||||
* on the debounced _fetchStat.
|
||||
*/
|
||||
fetchStats() {
|
||||
this.loading = true;
|
||||
this.env.bus.trigger('start-loading', {});
|
||||
this._fetchStats(); // debounced
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches data from the backend.
|
||||
*/
|
||||
async _fetchStats() {
|
||||
const fetchData = {
|
||||
...this.config,
|
||||
bundle_id: this.props.bundle_id,
|
||||
trigger_id: this.props.trigger_id,
|
||||
};
|
||||
const result = await fetch('/runbot/stats/', {
|
||||
body: JSON.stringify({params: fetchData}),
|
||||
method: 'POST',
|
||||
headers: {
|
||||
['Content-Type']: 'application/json',
|
||||
},
|
||||
});
|
||||
this.state.data = (await result.json()).result;
|
||||
this.env.bus.trigger('stop-loading', {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the chart data according to current data and layout.
|
||||
*/
|
||||
_computeChartData() {
|
||||
if (!this.state.data || Object.keys(this.state.data).length === 0) {
|
||||
this.chartConfig.data = {
|
||||
labels: [],
|
||||
datasets: [],
|
||||
};
|
||||
return;
|
||||
}
|
||||
const {
|
||||
display_aggregate: aggregate,
|
||||
mode,
|
||||
} = this.config;
|
||||
const { data } = this.state;
|
||||
const builds = Object.keys(data);
|
||||
const newestBuildStats = data[builds[builds.length - 1]];
|
||||
const oldestBuildStats = data[builds[0]];
|
||||
const keys = Object.keys(newestBuildStats);
|
||||
let idx = keys.indexOf('Aggregate Sum');
|
||||
if (aggregate === 'sum' && idx === -1) {
|
||||
keys.push('Aggregate Sum');
|
||||
Object.values(data).forEach((buildData) => {
|
||||
buildData['Aggregate Sum'] = Object.values(buildData).reduce((a, b) => a + b, 0);
|
||||
});
|
||||
} else if (aggregate !== 'sum' && idx !== -1) {
|
||||
keys.splice(idx, 1);
|
||||
}
|
||||
idx = keys.indexOf('Aggregate Average');
|
||||
if (aggregate === 'average' && idx === -1) {
|
||||
keys.push('Aggregate Average');
|
||||
Object.values(data).forEach((buildData) => {
|
||||
buildData['Aggregate Average'] = (Object.values(buildData).reduce((a, b) => a + b, 0) / Object.values(buildData).length);
|
||||
});
|
||||
} else if (aggregate !== 'average' && idx !== -1) {
|
||||
keys.splice(idx, 1);
|
||||
}
|
||||
// Mapping of keys to their sort value
|
||||
const sortValues = keys.reduce(
|
||||
(dict, key) => {
|
||||
const getValue = () => {
|
||||
if (mode === 'normal') {
|
||||
return newestBuildStats[key];
|
||||
} else if (mode === 'alpha') {
|
||||
return key;
|
||||
} else if (mode === 'change_count') {
|
||||
return builds.reduce((agg, build, buildIdx) => {
|
||||
const currentBuild = data[build];
|
||||
const current = currentBuild[key];
|
||||
const previous = buildIdx === 0 ? undefined : data[builds[buildIdx - 1]][key];
|
||||
if (previous !== undefined && current !== undefined && previous != current) {
|
||||
agg += 1;
|
||||
}
|
||||
return agg;
|
||||
}, 0);
|
||||
} else if (mode === 'difference') {
|
||||
return Math.abs(
|
||||
newestBuildStats[key] - (oldestBuildStats[key] || 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
dict[key] = getValue();
|
||||
return dict;
|
||||
}, {},
|
||||
);
|
||||
keys.sort((k1, k2) => sortValues[k2] - sortValues[k1]);
|
||||
let visibleKeys;
|
||||
if (this.config.nb_dataset !== -1) {
|
||||
visibleKeys = new Set(keys.slice(0, this.config.nb_dataset));
|
||||
} else {
|
||||
visibleKeys = new Set(this.config.getVisibleKeys());
|
||||
}
|
||||
const getDisplayValue = (key, build) => {
|
||||
if (build[key] === undefined) {
|
||||
return NaN;
|
||||
}
|
||||
if (mode === 'normal' || mode === 'alpha') {
|
||||
return build[key];
|
||||
}
|
||||
return build[key] - (oldestBuildStats[key] || 0)
|
||||
}
|
||||
this.chartConfig.data = {
|
||||
labels: builds,
|
||||
datasets: keys.map((key) => ({
|
||||
label: key,
|
||||
data: builds.map(build => getDisplayValue(key, data[build])),
|
||||
borderColor: randomColor(key),
|
||||
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||
lineTension: 0,
|
||||
hidden: !visibleKeys.has(key),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute chart data and trigger an update on the chart.
|
||||
* If the canvas is not set, nothing happens.
|
||||
*
|
||||
* @param {Boolean} recompute whether to recompute the chart's dataset or not.
|
||||
*/
|
||||
updateChart(recompute = true) {
|
||||
if (!this.canvas || !this.canvas.el) {
|
||||
return
|
||||
}
|
||||
if (recompute) {
|
||||
this._computeChartData();
|
||||
}
|
||||
if (!this.chart) {
|
||||
this.chart = new Chart(this.canvas.el.getContext('2d'), this.chartConfig);
|
||||
} else {
|
||||
this.chart.update();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes the visible keys from the current chart config.
|
||||
*/
|
||||
_pushCurrentVisibleKeys() {
|
||||
this.config.pushVisibleKeys(
|
||||
this.chartConfig.data.datasets.filter(ds => !ds.hidden).map(ds => ds.label)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles an item between visible states in the chart.
|
||||
*
|
||||
* @param {String} key the item to toggle
|
||||
*/
|
||||
onClickLegendItem(key) {
|
||||
const dataset = this.chartConfig.data.datasets.find(ds => ds.label === key);
|
||||
if (!dataset) {
|
||||
return; //Handle error?
|
||||
}
|
||||
const isVisible = !dataset.hidden;
|
||||
// If we were using a custom top N, we need to update the visible_keys parameter
|
||||
if (this.config.nb_dataset !== -1) {
|
||||
this._pushCurrentVisibleKeys();
|
||||
this.config.nb_dataset = -1;
|
||||
}
|
||||
this.config.toggleVisibleKey(key);
|
||||
if (isVisible) {
|
||||
dataset.hidden = true;
|
||||
} else {
|
||||
dataset.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when nb_dataset select field is changed.
|
||||
*
|
||||
* @param {Event} ev the event
|
||||
*/
|
||||
onChangeNbDataset(ev) {
|
||||
const { target } = ev;
|
||||
const value = parseInt(target.value);
|
||||
if (value === -1) {
|
||||
this._pushCurrentVisibleKeys();
|
||||
} else {
|
||||
this.config.pushVisibleKeys([]);
|
||||
}
|
||||
this.config.nb_dataset = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first build as the center build for the next fetch.
|
||||
*/
|
||||
selectPrevious() {
|
||||
const builds = Object.keys(this.state.data);
|
||||
if (!builds || !builds.length) {
|
||||
return
|
||||
}
|
||||
this.config.center_build_id = builds[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the last build as the center build for the next fetch.
|
||||
*/
|
||||
selectNext() {
|
||||
const builds = Object.keys(this.state.data);
|
||||
if (!builds || !builds.length) {
|
||||
return
|
||||
}
|
||||
this.config.center_build_id = builds[builds.length - 1];
|
||||
}
|
||||
}
|
||||
|
44
runbot/static/src/stats/stats_chart.xml
Normal file
44
runbot/static/src/stats/stats_chart.xml
Normal file
@ -0,0 +1,44 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="runbot.StatsChart">
|
||||
<div class="row">
|
||||
<div class="col-xs-9 col-md-10"><canvas t-ref="canvas"/></div>
|
||||
<div class="col-xs-3 col-md-2">
|
||||
<b>Mode:</b>
|
||||
<select class="form-select" aria-label="Display Mode" t-model="config.mode">
|
||||
<option title="Real Values ordered by value" value="normal">Value</option>
|
||||
<option title="Real Values ordered by name" value="alpha">Alphabetical</option>
|
||||
<option title="Delta With Reference Build Values" value="difference">Difference</option>
|
||||
<option title="Bigger # of datapoint varying from previous one" value="change_count">Noisy</option>
|
||||
</select>
|
||||
<br/>
|
||||
<b>Display:</b>
|
||||
<select class="form-select" aria-label="Number Of Builds" t-on-change="(ev) => this.onChangeNbDataset(ev)">
|
||||
<option value="-1">Custom</option>
|
||||
<option value="0">0</option>
|
||||
<option value="10">Top 10</option>
|
||||
<option value="20">Top 20</option>
|
||||
<option value="50">Top 50</option>
|
||||
</select>
|
||||
<br/>
|
||||
<b>Display aggregate:</b>
|
||||
<select class="form-select" aria-label="Display sum" t-model="config.display_aggregate">
|
||||
<option value="none">No</option>
|
||||
<option value="sum">Sum</option>
|
||||
<option value="average">Average</option>
|
||||
</select><br/>
|
||||
<!-- legend -->
|
||||
<div class="chart-legend" t-if="chartConfig.data?.datasets">
|
||||
<ul>
|
||||
<li t-foreach="chartConfig.data?.datasets" t-as="dataset" t-key="dataset.label" t-att-class="dataset.hidden ? 'disabled' : undefined"
|
||||
t-on-click.stop.prevent="() => this.onClickLegendItem(dataset.label)"
|
||||
>
|
||||
<span class="color" t-attf-style="border: 2px solid {{dataset.borderColor}}"/>
|
||||
<span class="label" t-att-title="dataset.label" t-out="dataset.label"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</odoo>
|
46
runbot/static/src/stats/stats_config.js
Normal file
46
runbot/static/src/stats/stats_config.js
Normal file
@ -0,0 +1,46 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from '@odoo/owl';
|
||||
|
||||
import { useBus } from '@runbot/stats/use_bus';
|
||||
import { useConfig } from '@runbot/stats/use_config';
|
||||
|
||||
|
||||
export class StatsConfig extends Component {
|
||||
static template = 'runbot.StatsConfig';
|
||||
static props = {
|
||||
bundle: {
|
||||
type: Object,
|
||||
shape: {
|
||||
id: { type: Number },
|
||||
name: { type: String },
|
||||
},
|
||||
},
|
||||
trigger: {
|
||||
type: Object,
|
||||
shape: {
|
||||
id: { type: Number },
|
||||
name: { type: String },
|
||||
},
|
||||
},
|
||||
stats_categories: { type: Array, element: String },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
})
|
||||
this.config = useConfig();
|
||||
|
||||
useBus(this.env.bus, 'start-loading', () => this.state.loading = true);
|
||||
useBus(this.env.bus, 'stop-loading', () => this.state.loading = false);
|
||||
}
|
||||
|
||||
onClickPrevious() {
|
||||
this.env.bus.trigger('click-previous', {});
|
||||
}
|
||||
|
||||
onClickNext() {
|
||||
this.env.bus.trigger('click-next', {});
|
||||
}
|
||||
};
|
36
runbot/static/src/stats/stats_config.xml
Normal file
36
runbot/static/src/stats/stats_config.xml
Normal file
@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="runbot.StatsConfig">
|
||||
<nav class="navbar navbar-light">
|
||||
<div class="container">
|
||||
<b>Bundle:</b><t t-out="props.bundle.name"/>
|
||||
<b>Trigger:</b><t t-out="props.trigger.name"/>
|
||||
<b>Stat Category:</b>
|
||||
<select class="form-select text-capitalize" aria-label="Stat Category" t-model="config.key_category">
|
||||
<option t-foreach="props.stats_categories" t-as="category" t-key="category" t-attf-value="{{category}}">
|
||||
<t t-out="category.replaceAll('_', ' ')"/>
|
||||
</option>
|
||||
</select>
|
||||
<b>Nb of builds:</b>
|
||||
<select class="form-select" aria-label="Number Of Builds" t-model.number="config.limit">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
</select>
|
||||
<button class="btn btn-default" title="Previous Builds" aria-label="Previous Builds" t-on-click="() => this.onClickPrevious()">
|
||||
<i t-attf-class="fa fa-backward"/>
|
||||
</button>
|
||||
<button class="btn btn-default" title="Next Builds" aria-label="Next Builds" t-on-click="() => this.onClickNext()">
|
||||
<i t-attf-class="fa fa-forward"/>
|
||||
</button>
|
||||
<button t-if="config.center_build_id != 0" class="btn btn-default" title="Latest Builds" aria-label="Latest Builds" t-on-click="() => this.config.center_build_id = 0">
|
||||
<i t-attf-class="fa fa-fast-forward"/>
|
||||
</button>
|
||||
<p>Tips: click a bullet to see corresponding build stats, shift+click to center the graph on this build</p>
|
||||
<i t-if="state.loading" class="fa fa-2x fa-circle-o-notch fa-spin"/>
|
||||
</div>
|
||||
</nav>
|
||||
</t>
|
||||
</odoo>
|
52
runbot/static/src/stats/stats_root.js
Normal file
52
runbot/static/src/stats/stats_root.js
Normal file
@ -0,0 +1,52 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, whenReady, App, EventBus, useSubEnv } from '@odoo/owl';
|
||||
import { getTemplate } from '@web/core/templates';
|
||||
|
||||
import { StatsConfig } from '@runbot/stats/stats_config';
|
||||
import { StatsChart } from '@runbot/stats/stats_chart';
|
||||
import { useConfig } from '@runbot/stats/use_config';
|
||||
import { UrlUpdater } from '@runbot/stats/url_updater';
|
||||
|
||||
|
||||
export class StatsRoot extends Component {
|
||||
static template = 'runbot.StatsRoot';
|
||||
static components = {StatsConfig, StatsChart, UrlUpdater};
|
||||
static props = {
|
||||
bundle: {
|
||||
type: Object,
|
||||
shape: {
|
||||
id: { type: Number },
|
||||
name: { type: String },
|
||||
},
|
||||
},
|
||||
trigger: {
|
||||
type: Object,
|
||||
shape: {
|
||||
id: { type: Number },
|
||||
name: { type: String },
|
||||
},
|
||||
},
|
||||
stats_categories: { type: Array, element: String },
|
||||
};
|
||||
|
||||
setup() {
|
||||
// Initialize shared configuration for children components.
|
||||
useConfig(false);
|
||||
|
||||
// Bus for communicating between children
|
||||
useSubEnv({
|
||||
bus: new EventBus(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
whenReady(() => {
|
||||
const rootElement = document.getElementById('wrapwrap');
|
||||
if (!rootElement || !globalThis.__runbot_stats_values) {
|
||||
return console.error('Could not initialize stats, wrapwrap not found');
|
||||
}
|
||||
rootElement.textContent = '';
|
||||
const app = new App(StatsRoot, { props: globalThis.__runbot_stats_values, getTemplate });
|
||||
app.mount(rootElement);
|
||||
});
|
12
runbot/static/src/stats/stats_root.xml
Normal file
12
runbot/static/src/stats/stats_root.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="runbot.StatsRoot">
|
||||
<div class="container-fluid">
|
||||
<UrlUpdater/>
|
||||
<!-- Controls -->
|
||||
<StatsConfig t-props="props"/>
|
||||
<!-- Chart -->
|
||||
<StatsChart bundle_id="props.bundle.id" trigger_id="props.trigger.id"/>
|
||||
</div>
|
||||
</t>
|
||||
</odoo>
|
16
runbot/static/src/stats/url_updater.js
Normal file
16
runbot/static/src/stats/url_updater.js
Normal file
@ -0,0 +1,16 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from '@odoo/owl';
|
||||
import { useConfig, onConfigChange } from '@runbot/stats/use_config';
|
||||
|
||||
|
||||
export class UrlUpdater extends Component {
|
||||
static template = 'runbot.UrlUpdater';
|
||||
static components = {};
|
||||
|
||||
setup() {
|
||||
onConfigChange((config) => {
|
||||
config.updateSearchParams();
|
||||
});
|
||||
}
|
||||
}
|
4
runbot/static/src/stats/url_updater.xml
Normal file
4
runbot/static/src/stats/url_updater.xml
Normal file
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<t t-name="runbot.UrlUpdater"></t>
|
||||
</odoo>
|
22
runbot/static/src/stats/use_bus.js
Normal file
22
runbot/static/src/stats/use_bus.js
Normal file
@ -0,0 +1,22 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { useComponent, useEffect } from '@odoo/owl';
|
||||
|
||||
/**
|
||||
* Ensures a bus event listener is attached and cleared the proper way.
|
||||
*
|
||||
* @param {import("@odoo/owl").EventBus} bus
|
||||
* @param {string} eventName
|
||||
* @param {EventListener} callback
|
||||
*/
|
||||
export function useBus(bus, eventName, callback) {
|
||||
const component = useComponent();
|
||||
useEffect(
|
||||
() => {
|
||||
const listener = callback.bind(component);
|
||||
bus.addEventListener(eventName, listener);
|
||||
return () => bus.removeEventListener(eventName, listener);
|
||||
},
|
||||
() => [],
|
||||
);
|
||||
}
|
151
runbot/static/src/stats/use_config.js
Normal file
151
runbot/static/src/stats/use_config.js
Normal file
@ -0,0 +1,151 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { reactive, useEffect, useState, useEnv, useSubEnv } from '@odoo/owl';
|
||||
|
||||
|
||||
/**
|
||||
* Search configuration for the stat page.
|
||||
*/
|
||||
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 = '',
|
||||
}) {
|
||||
this.limit = limit;
|
||||
this.center_build_id = center_build_id;
|
||||
this.key_category = key_category;
|
||||
this.mode = mode;
|
||||
this.nb_dataset = nb_dataset;
|
||||
this.display_aggregate = display_aggregate;
|
||||
this.visible_keys = visible_keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the url hash to fetch the default configuration.
|
||||
*
|
||||
* @returns new configuration from current url hash
|
||||
*/
|
||||
static fromSearchParams() {
|
||||
const config = Object.fromEntries(new URLSearchParams(window.location.hash.substring(1)));
|
||||
const numberKeys = ['limit', 'center_build_id', 'nb_dataset'];
|
||||
numberKeys.forEach((key) => {
|
||||
if (!(key in config)) {
|
||||
return;
|
||||
}
|
||||
const sVal = config[key];
|
||||
if (isNaN(sVal)) {
|
||||
delete config[key];
|
||||
} else {
|
||||
config[key] = parseInt(sVal);
|
||||
}
|
||||
})
|
||||
return new Config(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the url hash according to the current state of the config.
|
||||
*/
|
||||
updateSearchParams() {
|
||||
window.location.hash = `#${new URLSearchParams({...this}).toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a set of keys that should trigger a refetch, other keys are treated as
|
||||
* display settings.
|
||||
*
|
||||
* @returns {string[]} set of keys that should trigger a refetch
|
||||
*/
|
||||
getRefetchKeys() {
|
||||
return [
|
||||
'limit', 'center_build_id', 'key_category',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a set of keys that should trigger a chart update _only_.
|
||||
*
|
||||
* @returns {string[]} set of keys that should trigger a chart update
|
||||
*/
|
||||
getChartUpdateKeys() {
|
||||
return ['mode', 'nb_dataset', 'display_aggregate', 'visible_keys'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the visible keys as an array instead of string.
|
||||
*
|
||||
* @returns {string[]} list of visible keys
|
||||
*/
|
||||
getVisibleKeys() {
|
||||
return this.visible_keys.split('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the given visible keys as visible keys.
|
||||
*
|
||||
* @param {string[]} keys the keys to add
|
||||
*/
|
||||
pushVisibleKeys(keys) {
|
||||
this.visible_keys = keys.join('-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the given key from visible keys.
|
||||
*
|
||||
* @param {string} key the key to toggle
|
||||
*/
|
||||
toggleVisibleKey(key) {
|
||||
const keys = this.getVisibleKeys();
|
||||
const keyIdx = keys.indexOf(key);
|
||||
if (keyIdx === -1) {
|
||||
keys.push(key);
|
||||
} else {
|
||||
keys.splice(keyIdx, 1);
|
||||
}
|
||||
this.pushVisibleKeys(keys);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current configuration note that the component is not made reactive directly.
|
||||
* If the configuration is non existant (parent element) a config is created through `fromSearchParams`.
|
||||
*
|
||||
* @returns {Config} config
|
||||
*/
|
||||
export const useConfig = (makeReactive = true) => {
|
||||
const env = useEnv();
|
||||
if (env.statsConfig) {
|
||||
if (makeReactive) {
|
||||
return useState(env.statsConfig);
|
||||
}
|
||||
return env.statsConfig;
|
||||
}
|
||||
const statsConfig = reactive(Config.fromSearchParams());
|
||||
useSubEnv({
|
||||
statsConfig,
|
||||
});
|
||||
if (makeReactive) {
|
||||
return useState(statsConfig);
|
||||
}
|
||||
return statsConfig;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @callback OnConfigChangeCallback
|
||||
*
|
||||
* @param {Config} config
|
||||
*/
|
||||
/**
|
||||
* Calls the callback any time the config changes.
|
||||
*
|
||||
* @param {OnConfigChangeCallback} callback method to call back
|
||||
* @param {Boolean} forRefetch if the callback needs to be called for data refresh only
|
||||
*/
|
||||
export const onConfigChange = (callback, forRefetch = false) => {
|
||||
const config = useConfig();
|
||||
const keys = forRefetch ? config.getRefetchKeys() : Object.keys(config);
|
||||
useEffect(
|
||||
() => callback(config),
|
||||
() => keys.map(k => config[k]),
|
||||
);
|
||||
}
|
46
runbot/static/src/utils.js
Normal file
46
runbot/static/src/utils.js
Normal file
@ -0,0 +1,46 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
/**
|
||||
* Creates a debounced version of a function.
|
||||
*
|
||||
* @template {Function} T Initial type of fn
|
||||
* @param {T} fn The function to debounce
|
||||
* @param {Number} delay The number of milliseconds to debounce
|
||||
*
|
||||
* @return {T} The debounced function
|
||||
*/
|
||||
export const debounce = (fn, delay = 500) => {
|
||||
let handle;
|
||||
return (...args) => {
|
||||
clearTimeout(handle);
|
||||
handle = setTimeout(() => {
|
||||
fn(...args);
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministically determine a color for a given object.
|
||||
* The object is stringified then hashed into a color index.
|
||||
*
|
||||
* @param {Object} any object to hash
|
||||
*/
|
||||
export const randomColor = (name) => {
|
||||
const colors = ['#004acd', '#3658c3', '#4a66ba', '#5974b2', '#6581aa', '#6f8fa3', '#7a9c9d', '#85a899', '#91b596', '#a0c096', '#fdaf56', '#f89a59', '#f1865a', '#e87359', '#dc6158', '#ce5055', '#bf4150', '#ad344b', '#992a45', '#84243d'];
|
||||
let sum = 0;
|
||||
const str = JSON.stringify(name);
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
sum += str.charCodeAt(i);
|
||||
}
|
||||
return colors[sum % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters an object according to some given keys.
|
||||
*
|
||||
* @param {Object} obj object to filter
|
||||
* @param {string[]} keys keys to keep
|
||||
*/
|
||||
export const filterKeys = (obj, keys) => {
|
||||
return Object.fromEntries(keys.map(k => [k, obj[k]]));
|
||||
}
|
@ -64,74 +64,18 @@
|
||||
|
||||
<template id="runbot.modules_stats">
|
||||
<t t-call='runbot.layout'>
|
||||
<input type="hidden" id="bundle_id" t-att-value="bundle.id"/>
|
||||
<input type="hidden" id="trigger_id" t-att-value="trigger.id"/>
|
||||
<div class="container-fluid">
|
||||
<nav class="navbar navbar-light">
|
||||
<div class="container">
|
||||
<b>Bundle:</b><t t-out="bundle.name"/>
|
||||
<b>Trigger:</b><t t-out="trigger.name"/>
|
||||
<b>Stat Category:</b>
|
||||
<select id="key_category_selector" class="form-select" aria-label="Stat Category">
|
||||
<option t-foreach="stats_categories" t-as="category" t-attf-value="{{category}}"><t t-out="category.replace('_',' ').title()"/></option>
|
||||
</select>
|
||||
<b>Nb of builds:</b>
|
||||
<select id="limit_selector" class="form-select" aria-label="Number Of Builds">
|
||||
<option value="10">10</option>
|
||||
<option value="25">25</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="250">250</option>
|
||||
</select>
|
||||
<button id="backward_button" class="btn btn-default" title="Previous Builds" aria-label="Previous Builds">
|
||||
<i t-attf-class="fa fa-backward"/>
|
||||
</button>
|
||||
<button id="forward_button" class="btn btn-default" title="Previous Builds" aria-label="Previous Builds">
|
||||
<i t-attf-class="fa fa-forward"/>
|
||||
</button>
|
||||
<button id="fast_forward_button" class="btn btn-default" title="Previous Builds" aria-label="Previous Builds">
|
||||
<i t-attf-class="fa fa-fast-forward"/>
|
||||
</button>
|
||||
<p>Tips: click a bullet to see corresponding build stats, shift+click to center the graph on this build</p>
|
||||
<i id="chart_spinner" class="fa fa-2x fa-circle-o-notch fa-spin"/>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="row">
|
||||
<div class="col-xs-9 col-md-10"><canvas id="canvas"></canvas></div>
|
||||
<div class="col-xs-3 col-md-2">
|
||||
<b>Mode:</b>
|
||||
<select id="mode_selector" class="form-select" aria-label="Display mode">
|
||||
<option title="Real Values ordered by value" selected="selected" value="normal">Value</option>
|
||||
<option title="Real Values ordered by name" selected="selected" value="alpha">Alphabetical</option>
|
||||
<option title="Delta With Reference Build Values" value="difference">Difference</option>
|
||||
<option title="Bigger # of datapoint varying from previous one" value="change_count">Noisy</option>
|
||||
</select><br/>
|
||||
|
||||
<b>Display:</b>
|
||||
<select id="nb_dataset_selector" class="form-select" aria-label="Number Of Builds">
|
||||
<option value="-1">Custom</option>
|
||||
<option value="0">0</option>
|
||||
<option value="10">Top 10</option>
|
||||
<option value="20">Top 20</option>
|
||||
<option value="50">Top 50</option>
|
||||
</select><br/>
|
||||
|
||||
<b>Display aggregate:</b>
|
||||
<select id="display_aggregate_selector" class="form-select" aria-label="Display sum">
|
||||
<option selected="selected" value="none">No</option>
|
||||
<option value="sum">Sum</option>
|
||||
<option value="average">Average</option>
|
||||
</select><br/>
|
||||
<div id="js-legend" class="chart-legend">
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
// Initial values used by the config.
|
||||
__runbot_stats_values = <t t-out="json.dumps({
|
||||
'bundle': {'id': bundle.id, 'name': bundle.name},
|
||||
'trigger': {'id': trigger.id, 'name': trigger.name},
|
||||
'stats_categories': stats_categories,
|
||||
})"/>;
|
||||
</script>
|
||||
<div id="wrapwrap">This page requires javascript to load</div>
|
||||
</t>
|
||||
<script type="text/javascript" src="/web/static/lib/Chart/Chart.js"></script>
|
||||
<script type="text/javascript" src="/runbot/static/src/js/stats.js"></script>
|
||||
<t t-call-assets="runbot.assets_stats"/>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
|
Loading…
Reference in New Issue
Block a user