diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py
index eab898c66..271642eeb 100644
--- a/runbot/__manifest__.py
+++ b/runbot/__manifest__.py
@@ -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',
diff --git a/runbot/static/src/chartjs_module.js b/runbot/static/src/chartjs_module.js
new file mode 100644
index 000000000..39e293c45
--- /dev/null
+++ b/runbot/static/src/chartjs_module.js
@@ -0,0 +1,5 @@
+odoo.define("@runbot/chartjs", [], function () {
+ "use strict";
+
+ return Chart;
+});
diff --git a/runbot/static/src/css/runbot.css b/runbot/static/src/css/runbot.css
index 89c79f326..f59235ebb 100644
--- a/runbot/static/src/css/runbot.css
+++ b/runbot/static/src/css/runbot.css
@@ -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;
diff --git a/runbot/static/src/js/stats.js b/runbot/static/src/js/stats.js
deleted file mode 100644
index c08ea5a9b..000000000
--- a/runbot/static/src/js/stats.js
+++ /dev/null
@@ -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 = $("
");
- for (data of config.data.datasets) {
- var legendElement = $(`${data.label}`)
- 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);
-};
diff --git a/runbot/static/src/stats/stats.scss b/runbot/static/src/stats/stats.scss
new file mode 100644
index 000000000..2cf633b03
--- /dev/null
+++ b/runbot/static/src/stats/stats.scss
@@ -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;
+ }
+}
diff --git a/runbot/static/src/stats/stats_chart.js b/runbot/static/src/stats/stats_chart.js
new file mode 100644
index 000000000..94c9c91e3
--- /dev/null
+++ b/runbot/static/src/stats/stats_chart.js
@@ -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];
+ }
+}
+
diff --git a/runbot/static/src/stats/stats_chart.xml b/runbot/static/src/stats/stats_chart.xml
new file mode 100644
index 000000000..a9cbcf16c
--- /dev/null
+++ b/runbot/static/src/stats/stats_chart.xml
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
Mode:
+
+
+
Display:
+
+
+
Display aggregate:
+
+
+
+
+
+
+
diff --git a/runbot/static/src/stats/stats_config.js b/runbot/static/src/stats/stats_config.js
new file mode 100644
index 000000000..a74290583
--- /dev/null
+++ b/runbot/static/src/stats/stats_config.js
@@ -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', {});
+ }
+};
diff --git a/runbot/static/src/stats/stats_config.xml b/runbot/static/src/stats/stats_config.xml
new file mode 100644
index 000000000..4e8478828
--- /dev/null
+++ b/runbot/static/src/stats/stats_config.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
diff --git a/runbot/static/src/stats/stats_root.js b/runbot/static/src/stats/stats_root.js
new file mode 100644
index 000000000..16484851a
--- /dev/null
+++ b/runbot/static/src/stats/stats_root.js
@@ -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);
+});
diff --git a/runbot/static/src/stats/stats_root.xml b/runbot/static/src/stats/stats_root.xml
new file mode 100644
index 000000000..f1ce778a1
--- /dev/null
+++ b/runbot/static/src/stats/stats_root.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/runbot/static/src/stats/url_updater.js b/runbot/static/src/stats/url_updater.js
new file mode 100644
index 000000000..a6f89281b
--- /dev/null
+++ b/runbot/static/src/stats/url_updater.js
@@ -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();
+ });
+ }
+}
diff --git a/runbot/static/src/stats/url_updater.xml b/runbot/static/src/stats/url_updater.xml
new file mode 100644
index 000000000..e93c9b58f
--- /dev/null
+++ b/runbot/static/src/stats/url_updater.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/runbot/static/src/stats/use_bus.js b/runbot/static/src/stats/use_bus.js
new file mode 100644
index 000000000..5402754ae
--- /dev/null
+++ b/runbot/static/src/stats/use_bus.js
@@ -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);
+ },
+ () => [],
+ );
+}
diff --git a/runbot/static/src/stats/use_config.js b/runbot/static/src/stats/use_config.js
new file mode 100644
index 000000000..47574570a
--- /dev/null
+++ b/runbot/static/src/stats/use_config.js
@@ -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]),
+ );
+}
diff --git a/runbot/static/src/utils.js b/runbot/static/src/utils.js
new file mode 100644
index 000000000..2d1171238
--- /dev/null
+++ b/runbot/static/src/utils.js
@@ -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]]));
+}
diff --git a/runbot/templates/build_stats.xml b/runbot/templates/build_stats.xml
index 5c0d815d5..a6d4ebe15 100644
--- a/runbot/templates/build_stats.xml
+++ b/runbot/templates/build_stats.xml
@@ -64,74 +64,18 @@
-
-
-
-
-
-
-
-
Mode:
-
-
-
Display:
-
-
-
Display aggregate:
-
-
-
-
-
-
-
-
+
+ This page requires javascript to load
-
+