From e43827a99d5121a8d7cc32aadebfdc1a85e62c4c Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Thu, 9 Jan 2025 16:26:59 +0100 Subject: [PATCH] [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. --- runbot/__manifest__.py | 14 ++ runbot/static/src/chartjs_module.js | 5 + runbot/static/src/css/runbot.css | 29 --- runbot/static/src/js/stats.js | 298 ----------------------- runbot/static/src/stats/stats.scss | 34 +++ runbot/static/src/stats/stats_chart.js | 298 +++++++++++++++++++++++ runbot/static/src/stats/stats_chart.xml | 44 ++++ runbot/static/src/stats/stats_config.js | 46 ++++ runbot/static/src/stats/stats_config.xml | 36 +++ runbot/static/src/stats/stats_root.js | 52 ++++ runbot/static/src/stats/stats_root.xml | 12 + runbot/static/src/stats/url_updater.js | 16 ++ runbot/static/src/stats/url_updater.xml | 4 + runbot/static/src/stats/use_bus.js | 22 ++ runbot/static/src/stats/use_config.js | 151 ++++++++++++ runbot/static/src/utils.js | 46 ++++ runbot/templates/build_stats.xml | 76 +----- 17 files changed, 790 insertions(+), 393 deletions(-) create mode 100644 runbot/static/src/chartjs_module.js delete mode 100644 runbot/static/src/js/stats.js create mode 100644 runbot/static/src/stats/stats.scss create mode 100644 runbot/static/src/stats/stats_chart.js create mode 100644 runbot/static/src/stats/stats_chart.xml create mode 100644 runbot/static/src/stats/stats_config.js create mode 100644 runbot/static/src/stats/stats_config.xml create mode 100644 runbot/static/src/stats/stats_root.js create mode 100644 runbot/static/src/stats/stats_root.xml create mode 100644 runbot/static/src/stats/url_updater.js create mode 100644 runbot/static/src/stats/url_updater.xml create mode 100644 runbot/static/src/stats/use_bus.js create mode 100644 runbot/static/src/stats/use_config.js create mode 100644 runbot/static/src/utils.js 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 @@