mirror of
https://github.com/odoo/runbot.git
synced 2025-03-27 13:25:47 +07:00
[IMP] runbot: display stats per bundle
When selecting multiple bundles, differentiate stats between bundles instead of displaying them all as the same.
This commit is contained in:
parent
47fcb9dcb3
commit
3ed0ef658a
@ -3,7 +3,6 @@ import datetime
|
|||||||
import werkzeug
|
import werkzeug
|
||||||
import logging
|
import logging
|
||||||
import functools
|
import functools
|
||||||
import itertools
|
|
||||||
|
|
||||||
import werkzeug.utils
|
import werkzeug.utils
|
||||||
import werkzeug.urls
|
import werkzeug.urls
|
||||||
@ -600,7 +599,7 @@ class Runbot(Controller):
|
|||||||
builds = request.env['runbot.build'].with_context(active_test=False)
|
builds = request.env['runbot.build'].with_context(active_test=False)
|
||||||
if center_build_id:
|
if center_build_id:
|
||||||
builds = builds.search(
|
builds = builds.search(
|
||||||
expression.AND([builds_domain, [('id', '>=', center_build_id)]]),
|
expression.AND([builds_domain, [('id', '>', center_build_id)]]),
|
||||||
order='id', limit=limit/2)
|
order='id', limit=limit/2)
|
||||||
builds_domain = expression.AND([builds_domain, [('id', '<=', center_build_id)]])
|
builds_domain = expression.AND([builds_domain, [('id', '<=', center_build_id)]])
|
||||||
limit -= len(builds)
|
limit -= len(builds)
|
||||||
@ -612,13 +611,28 @@ class Runbot(Controller):
|
|||||||
builds = builds.search([('id', 'child_of', builds.ids)])
|
builds = builds.search([('id', 'child_of', builds.ids)])
|
||||||
|
|
||||||
parents = {b.id: b.top_parent.id for b in builds.with_context(prefetch_fields=False)}
|
parents = {b.id: b.top_parent.id for b in builds.with_context(prefetch_fields=False)}
|
||||||
|
# Prefetch bundle name, we need to be able to bind a
|
||||||
|
builds.with_context(prefetch_fields=False).fetch([
|
||||||
|
'create_date', 'slot_ids',
|
||||||
|
])
|
||||||
|
res_arr = [
|
||||||
|
{
|
||||||
|
'id': b.id,
|
||||||
|
'values': {},
|
||||||
|
'create_date': b.create_date.isoformat(),
|
||||||
|
'bundle_id': b.slot_ids.batch_id.bundle_id.id,
|
||||||
|
}
|
||||||
|
for b in builds.with_context(prefetch_fields=False).sorted('id')
|
||||||
|
]
|
||||||
|
res_dict = {r['id']: r['values'] for r in res_arr}
|
||||||
request.env.cr.execute("SELECT build_id, values FROM runbot_build_stat WHERE build_id IN %s AND category = %s", [tuple(builds.ids), key_category]) # read manually is way faster than using orm
|
request.env.cr.execute("SELECT build_id, values FROM runbot_build_stat WHERE build_id IN %s AND category = %s", [tuple(builds.ids), key_category]) # read manually is way faster than using orm
|
||||||
res = {}
|
|
||||||
for (build_id, values) in request.env.cr.fetchall():
|
for (build_id, values) in request.env.cr.fetchall():
|
||||||
if values:
|
if values:
|
||||||
res.setdefault(parents[build_id], {}).update(values)
|
res_dict[parents[build_id]].update(values)
|
||||||
# we need to update here to manage the post install case: we want to combine stats from all post_install childrens.
|
# we need to update here to manage the post install case: we want to combine stats from all post_install childrens.
|
||||||
return res
|
# Filter out results without values
|
||||||
|
res_arr = [r for r in res_arr if r['values']]
|
||||||
|
return res_arr
|
||||||
|
|
||||||
@route(['/runbot/stats/<model("runbot.bundle"):bundle>/<model("runbot.trigger"):trigger>'], type='http', auth="public", website=True, sitemap=False)
|
@route(['/runbot/stats/<model("runbot.bundle"):bundle>/<model("runbot.trigger"):trigger>'], type='http', auth="public", website=True, sitemap=False)
|
||||||
def modules_stats(self, bundle, trigger, search=None, **post):
|
def modules_stats(self, bundle, trigger, search=None, **post):
|
||||||
|
@ -13,8 +13,8 @@ class BuildStat(models.Model):
|
|||||||
|
|
||||||
_sql_constraints = [
|
_sql_constraints = [
|
||||||
(
|
(
|
||||||
"build_config_key_unique",
|
"category_build_config_step_unique_key",
|
||||||
"unique (build_id, config_step_id, category)",
|
"unique (category, build_id, config_step_id)",
|
||||||
"Build stats must be unique for the same build step",
|
"Build stats must be unique for the same build step",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -23,5 +23,5 @@ class BuildStat(models.Model):
|
|||||||
config_step_id = fields.Many2one(
|
config_step_id = fields.Many2one(
|
||||||
"runbot.build.config.step", "Step", ondelete="cascade"
|
"runbot.build.config.step", "Step", ondelete="cascade"
|
||||||
)
|
)
|
||||||
category = fields.Char("Category", index=True)
|
category = fields.Char("Category")
|
||||||
values = JsonDictField("Value")
|
values = JsonDictField("Value")
|
||||||
|
29
runbot/static/src/stats/cache.js
Normal file
29
runbot/static/src/stats/cache.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef Bundle
|
||||||
|
*
|
||||||
|
* @property {Number} id
|
||||||
|
* @property {String} name
|
||||||
|
*/
|
||||||
|
|
||||||
|
const bundleNameCache = {}; // id to name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the name of the bundle according to the cache.
|
||||||
|
*
|
||||||
|
* @param {Number} bundleId id of the bundle
|
||||||
|
*/
|
||||||
|
export const getBundleName = (bundleId) => {
|
||||||
|
return bundleNameCache[bundleId] || bundleId.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Populates the cache when a new list of bundle is loaded.
|
||||||
|
*
|
||||||
|
* @param {Bundle[]} bundles list of bundles
|
||||||
|
*/
|
||||||
|
export const populateCache = (bundles) => {
|
||||||
|
bundles.forEach(({ id, name }) => bundleNameCache[id] = name);
|
||||||
|
}
|
@ -5,8 +5,17 @@ import { Component, useEffect, useRef, useState } from '@odoo/owl';
|
|||||||
import { debounce, filterKeys, randomColor } from '@runbot/utils';
|
import { debounce, filterKeys, randomColor } from '@runbot/utils';
|
||||||
import { useConfig, onConfigChange } from '@runbot/stats/use_config';
|
import { useConfig, onConfigChange } from '@runbot/stats/use_config';
|
||||||
import { Chart } from '@runbot/chartjs';
|
import { Chart } from '@runbot/chartjs';
|
||||||
|
import { getBundleName } from '@runbot/stats/cache';
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef StatsQueryResult
|
||||||
|
*
|
||||||
|
* @property {Number} id id of the build
|
||||||
|
* @property {Object.<string, number>} values object with value name as key and value as value
|
||||||
|
* @property {String} create_date creation date of the build
|
||||||
|
* @property {Number} bundle_id bundle id of the build
|
||||||
|
*/
|
||||||
export class StatsChart extends Component {
|
export class StatsChart extends Component {
|
||||||
static template = 'runbot.StatsChart';
|
static template = 'runbot.StatsChart';
|
||||||
static props = {
|
static props = {
|
||||||
@ -39,6 +48,7 @@ export class StatsChart extends Component {
|
|||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
|
type: 'time',
|
||||||
display: true,
|
display: true,
|
||||||
scaleLabel: {
|
scaleLabel: {
|
||||||
display: true,
|
display: true,
|
||||||
@ -58,11 +68,16 @@ export class StatsChart extends Component {
|
|||||||
if (activeElements.length === 0) {
|
if (activeElements.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const build_id = this.chartConfig.data.labels[activeElements[0].index];
|
const { datasetIndex, index } = activeElements[0];
|
||||||
|
const queryResult = this.chartConfig.data.datasets[datasetIndex].data[index]._queryResult;
|
||||||
|
if (!queryResult) {
|
||||||
|
return console.error('Queryresult not present for datasetIndex and index ', datasetIndex, index);
|
||||||
|
}
|
||||||
|
const { id } = queryResult;
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
this.config.center_build_id = build_id;
|
this.config.center_build_id = id;
|
||||||
} else {
|
} else {
|
||||||
window.open(`/runbot/build/stats/${build_id}`);
|
window.open(`/runbot/build/stats/${id}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -142,89 +157,132 @@ export class StatsChart extends Component {
|
|||||||
const {
|
const {
|
||||||
display_aggregate: aggregate,
|
display_aggregate: aggregate,
|
||||||
mode,
|
mode,
|
||||||
|
nb_dataset,
|
||||||
} = this.config;
|
} = this.config;
|
||||||
const { data } = this.state;
|
|
||||||
const builds = Object.keys(data);
|
/** @type {StatsQueryResult[]} */
|
||||||
const newestBuildStats = data[builds[builds.length - 1]];
|
const data = this.state.data;
|
||||||
const oldestBuildStats = data[builds[0]];
|
const bundles = new Set(data.map(({bundle_id}) => bundle_id));
|
||||||
const keys = Object.keys(newestBuildStats);
|
const hasMultiBundle = bundles.size > 1;
|
||||||
let idx = keys.indexOf('Aggregate Sum');
|
/**
|
||||||
if (aggregate === 'sum' && idx === -1) {
|
* Gets the label (group by key) for the given queryResult
|
||||||
keys.push('Aggregate Sum');
|
*
|
||||||
Object.values(data).forEach((buildData) => {
|
* @param {StatsQueryResult} queryResult
|
||||||
buildData['Aggregate Sum'] = Object.values(buildData).reduce((a, b) => a + b, 0);
|
* @param {String} valueName
|
||||||
});
|
*/
|
||||||
} else if (aggregate !== 'sum' && idx !== -1) {
|
const getLabel = (queryResult, valueName) => {
|
||||||
keys.splice(idx, 1);
|
// Q: Change to bundleName if not main bundle?
|
||||||
|
if (hasMultiBundle) {
|
||||||
|
return `${valueName} (${getBundleName(queryResult.bundle_id)})`;
|
||||||
|
}
|
||||||
|
return `${valueName}`;
|
||||||
}
|
}
|
||||||
idx = keys.indexOf('Aggregate Average');
|
// Group values by (valueName, bundle Id) we want to separate bundle ids.
|
||||||
if (aggregate === 'average' && idx === -1) {
|
const datasets = Object.values(data.reduce((agg, queryResult) => {
|
||||||
keys.push('Aggregate Average');
|
Object.entries(queryResult.values).forEach(([valueName, value]) => {
|
||||||
Object.values(data).forEach((buildData) => {
|
const label = getLabel(queryResult, valueName);
|
||||||
buildData['Aggregate Average'] = (Object.values(buildData).reduce((a, b) => a + b, 0) / Object.values(buildData).length);
|
if (!(agg[label])) {
|
||||||
});
|
agg[label] = {
|
||||||
} else if (aggregate !== 'average' && idx !== -1) {
|
label,
|
||||||
keys.splice(idx, 1);
|
data: [],
|
||||||
}
|
borderColor: randomColor(label),
|
||||||
// Mapping of keys to their sort value
|
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||||
const sortValues = keys.reduce(
|
lineTension: 0,
|
||||||
(dict, key) => {
|
hidden: false,
|
||||||
const getValue = () => {
|
_queryResult: queryResult,
|
||||||
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();
|
agg[label].data.push({
|
||||||
return dict;
|
x: queryResult.create_date,
|
||||||
}, {},
|
y: value,
|
||||||
);
|
_queryResult: queryResult,
|
||||||
keys.sort((k1, k2) => {
|
});
|
||||||
if (mode === 'alpha') {
|
})
|
||||||
return sortValues[k1].localeCompare(sortValues[k2]);
|
return agg;
|
||||||
|
}, {}));
|
||||||
|
|
||||||
|
// Compute selected aggregate
|
||||||
|
if (aggregate != 'none') {
|
||||||
|
const queryResultsByBundle = data.reduce((agg, queryResult) => {
|
||||||
|
if (!(agg[queryResult.bundle_id])) {
|
||||||
|
agg[queryResult.bundle_id] = [];
|
||||||
|
}
|
||||||
|
agg[[queryResult.bundle_id]].push(queryResult);
|
||||||
|
return agg
|
||||||
|
}, {});
|
||||||
|
Object.values(queryResultsByBundle).forEach((queryResults) => {
|
||||||
|
const newData = queryResults.map((qs) => {
|
||||||
|
return {
|
||||||
|
x: qs.create_date,
|
||||||
|
y: Object.values(qs.values).reduce((s, v) => s + v, 0),
|
||||||
|
_queryResult: qs,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let label = getLabel(datasets[0]._queryResult, 'Aggregate Sum');
|
||||||
|
if (aggregate === 'average') {
|
||||||
|
label = getLabel(datasets[0]._queryResult, 'Aggregate Average');
|
||||||
|
newData.forEach(d => d.y /= Object.values(d._queryResult.values).length);
|
||||||
|
}
|
||||||
|
datasets.push({
|
||||||
|
label,
|
||||||
|
data: newData,
|
||||||
|
borderColor: randomColor(label),
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0)',
|
||||||
|
lineTension: 0,
|
||||||
|
hidden: false,
|
||||||
|
_queryResult: datasets[0]._queryResult,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Compute a sorting value for each dataset, sort
|
||||||
|
// Also recompute data if mode requires it.
|
||||||
|
datasets.forEach((dataset) => {
|
||||||
|
const getSortValue = () => {
|
||||||
|
if (mode === 'normal') {
|
||||||
|
return dataset.data[0].y;
|
||||||
|
} else if (mode === 'alpha') {
|
||||||
|
return dataset.label;
|
||||||
|
} else if (mode === 'change_count') {
|
||||||
|
return dataset.data.reduce((agg, {y}, dataIdx) => {
|
||||||
|
const previous = dataIdx === 0 ? undefined : dataset.data[dataIdx - 1].y;
|
||||||
|
if (previous !== undefined && y !== undefined && previous != y) {
|
||||||
|
agg += 1;
|
||||||
|
}
|
||||||
|
return agg;
|
||||||
|
}, 0);
|
||||||
|
} else if (mode === 'difference') {
|
||||||
|
return Math.abs(
|
||||||
|
dataset.data[dataset.data.length - 1].y - dataset.data[0].y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataset._sortValue = getSortValue();
|
||||||
|
if (mode === 'change_count' || mode === 'difference') {
|
||||||
|
const firstValue = dataset.data[0].y;
|
||||||
|
dataset.data = dataset.data.map(d => {
|
||||||
|
return {
|
||||||
|
...d,
|
||||||
|
y: d.y - firstValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return sortValues[k2] - sortValues[k1]
|
|
||||||
});
|
});
|
||||||
|
datasets.sort((ds1, ds2) => {
|
||||||
|
if (mode === 'alpha') {
|
||||||
|
return ds1._sortValue.localeCompare(ds2._sortValue);
|
||||||
|
}
|
||||||
|
return ds2._sortValue - ds1._sortValue;
|
||||||
|
});
|
||||||
|
// Change visibility of datasets according to config
|
||||||
let visibleKeys;
|
let visibleKeys;
|
||||||
if (this.config.nb_dataset !== -1) {
|
if (nb_dataset !== -1) {
|
||||||
visibleKeys = new Set(keys.slice(0, this.config.nb_dataset));
|
visibleKeys = new Set(datasets.slice(0, nb_dataset).map(ds => ds.label));
|
||||||
} else {
|
} else {
|
||||||
visibleKeys = new Set(this.config.getVisibleKeys());
|
visibleKeys = new Set(this.config.getVisibleKeys());
|
||||||
}
|
}
|
||||||
const getDisplayValue = (key, build) => {
|
datasets.forEach(ds => ds.hidden = !visibleKeys.has(ds.label));
|
||||||
if (build[key] === undefined) {
|
|
||||||
return NaN;
|
|
||||||
}
|
|
||||||
if (mode === 'normal' || mode === 'alpha') {
|
|
||||||
return build[key];
|
|
||||||
}
|
|
||||||
return build[key] - (oldestBuildStats[key] || 0)
|
|
||||||
}
|
|
||||||
this.chartConfig.data = {
|
this.chartConfig.data = {
|
||||||
labels: builds,
|
datasets,
|
||||||
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),
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,11 +16,11 @@
|
|||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
<label for="nb_dataset" class="form-label fw-bold mb-0">Display</label>
|
<label for="nb_dataset" class="form-label fw-bold mb-0">Display</label>
|
||||||
<select class="form-select" id="nb_dataset" aria-label="Number Of Builds" t-on-change="(ev) => this.onChangeNbDataset(ev)">
|
<select class="form-select" id="nb_dataset" aria-label="Number Of Builds" t-on-change="(ev) => this.onChangeNbDataset(ev)">
|
||||||
<option value="-1">Custom</option>
|
<option value="-1" t-att-selected="config.nb_dataset === -1 ? 'selected' : undefined">Custom</option>
|
||||||
<option value="0">0</option>
|
<option value="0" t-att-selected="config.nb_dataset === 0 ? 'selected' : undefined">0</option>
|
||||||
<option value="10">Top 10</option>
|
<option value="10" t-att-selected="config.nb_dataset === 10 ? 'selected' : undefined">Top 10</option>
|
||||||
<option value="20">Top 20</option>
|
<option value="20" t-att-selected="config.nb_dataset === 20 ? 'selected' : undefined">Top 20</option>
|
||||||
<option value="50">Top 50</option>
|
<option value="50" t-att-selected="config.nb_dataset === 50 ? 'selected' : undefined">Top 50</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-4">
|
<div class="col-4">
|
||||||
@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-xs-3 col-md-2">
|
<div class="col-xs-3 col-md-2">
|
||||||
<!-- legend -->
|
<!-- legend -->
|
||||||
<div class="chart-legend" t-if="chartConfig.data?.datasets">
|
<div class="chart-legend overflow-x-auto" t-if="chartConfig.data?.datasets">
|
||||||
<ul>
|
<ul>
|
||||||
<li t-foreach="chartConfig.data?.datasets" t-as="dataset" t-key="dataset.label" t-att-class="dataset.hidden ? 'disabled' : undefined"
|
<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)"
|
t-on-click.stop.prevent="() => this.onClickLegendItem(dataset.label)"
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
import { Component, useEffect, useState } from '@odoo/owl';
|
import { Component, useEffect, useState } from '@odoo/owl';
|
||||||
|
|
||||||
import { useConfig } from '@runbot/stats/use_config';
|
import { useConfig } from '@runbot/stats/use_config';
|
||||||
|
import { getBundleName, populateCache } from '@runbot/stats/cache';
|
||||||
import { debounce, randomColor } from '@runbot/utils';
|
import { debounce, randomColor } from '@runbot/utils';
|
||||||
|
|
||||||
|
|
||||||
@ -13,26 +14,6 @@ import { debounce, randomColor } from '@runbot/utils';
|
|||||||
* @property {String} name
|
* @property {String} name
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const bundleNameCache = {}; // id to name
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the name of the bundle according to the cache.
|
|
||||||
*
|
|
||||||
* @param {Number} bundleId id of the bundle
|
|
||||||
*/
|
|
||||||
const getBundleName = (bundleId) => {
|
|
||||||
return bundleNameCache[bundleId] || bundleId.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Populates the cache when a new list of bundle is loaded.
|
|
||||||
*
|
|
||||||
* @param {Bundle[]} bundles list of bundles
|
|
||||||
*/
|
|
||||||
const populateCache = (bundles) => {
|
|
||||||
bundles.forEach(({ id, name }) => bundleNameCache[id] = name);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class StatsConfig extends Component {
|
export class StatsConfig extends Component {
|
||||||
static template = 'runbot.StatsConfig';
|
static template = 'runbot.StatsConfig';
|
||||||
static props = {
|
static props = {
|
||||||
|
@ -7,6 +7,7 @@ import { StatsConfig } from '@runbot/stats/stats_config';
|
|||||||
import { StatsChart } from '@runbot/stats/stats_chart';
|
import { StatsChart } from '@runbot/stats/stats_chart';
|
||||||
import { useConfig } from '@runbot/stats/use_config';
|
import { useConfig } from '@runbot/stats/use_config';
|
||||||
import { UrlUpdater } from '@runbot/stats/url_updater';
|
import { UrlUpdater } from '@runbot/stats/url_updater';
|
||||||
|
import { populateCache } from '@runbot/stats/cache';
|
||||||
|
|
||||||
|
|
||||||
export class StatsRoot extends Component {
|
export class StatsRoot extends Component {
|
||||||
@ -54,6 +55,7 @@ export class StatsRoot extends Component {
|
|||||||
setup() {
|
setup() {
|
||||||
// Initialize shared configuration for children components.
|
// Initialize shared configuration for children components.
|
||||||
useConfig(false);
|
useConfig(false);
|
||||||
|
populateCache([this.props.bundle]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,6 +77,8 @@
|
|||||||
<div id="wrapwrap">This page requires javascript to load</div>
|
<div id="wrapwrap">This page requires javascript to load</div>
|
||||||
</t>
|
</t>
|
||||||
<script type="text/javascript" src="/web/static/lib/Chart/Chart.js"></script>
|
<script type="text/javascript" src="/web/static/lib/Chart/Chart.js"></script>
|
||||||
|
<script type="text/javascript" src="/web/static/lib/luxon/luxon.js"/>
|
||||||
|
<script type="text/javascript" src="/web/static/lib/chartjs-adapter-luxon/chartjs-adapter-luxon.js"/>
|
||||||
<t t-call-assets="runbot.assets_stats"/>
|
<t t-call-assets="runbot.assets_stats"/>
|
||||||
</template>
|
</template>
|
||||||
</data>
|
</data>
|
||||||
|
Loading…
Reference in New Issue
Block a user