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 @@