[IMP] runbot: build error frequency graphs

Adds frequency graphs for build errors as small html widgets.
All data is gathered from the last 30 days.

 - Daily frequency (how many times per day)
 - Hourly frequency (how many times per individual hour, 0-23)
 - Day Of Week frequency (how many times per day of week, 0-6)
 - Day of month frequency (how many times per day of month, 0-30)

Co-Authored-By: Xavier-Do <xdo@odoo.com>
This commit is contained in:
William Braeckman 2025-03-10 09:55:34 +01:00 committed by xdo
parent 637cdb81e7
commit baacd1e148
2 changed files with 99 additions and 0 deletions

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import datetime
import hashlib import hashlib
import json import json
import logging import logging
@ -6,6 +7,7 @@ import re
from collections import defaultdict from collections import defaultdict
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from dateutil import rrule
from markupsafe import Markup from markupsafe import Markup
from werkzeug.urls import url_join from werkzeug.urls import url_join
from odoo import models, fields, api from odoo import models, fields, api
@ -17,6 +19,18 @@ from ..fields import JsonDictField
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def get_color(value: int):
if value >= 10:
return 'red'
elif value >= 5:
return 'orange'
return 'green'
def draw_svg(values: list[int], max_value: int = 10, height: int = 30):
lines = ''.join(f'<line x1="0" x2="{len(values) * 10}" y1="{v * 10}" y2="{v * 10}" stroke="gray" stroke_width="1"/>' for v in range(0, max_value, 2))
rects = ''.join(f'<rect fill="{get_color(value)}" width="9" height="{min(value, max_value) * 10}" x="{idx * 10 + 0.5}" y="{(max_value - min(value, max_value)) * 10}"/>' for idx, value in enumerate(values))
return f'<div style="height: {height}px"><svg xmlns="https://www.w3.org/2000/svg" viewbox="0 0 {len(values) * 10} {max_value * 10}" style="border: 1px solid black; height: 100%; width: 100%;" preserveAspectRatio="none" shape-rendering="cripsEdges">{lines}{rects}</svg></div>'
class BuildErrorLink(models.Model): class BuildErrorLink(models.Model):
_name = 'runbot.build.error.link' _name = 'runbot.build.error.link'
_description = 'Build Build Error Extended Relation' _description = 'Build Build Error Extended Relation'
@ -123,6 +137,12 @@ class BuildError(models.Model):
random = fields.Boolean('Random', compute="_compute_random", store=True) random = fields.Boolean('Random', compute="_compute_random", store=True)
graph_history = fields.Html('30 days history', compute='_compute_graph', sanitize=False)
graph_hourly_recurence = fields.Html('Hourly recurence', compute='_compute_graph', sanitize=False)
graph_day_of_week_recurence = fields.Html('Weekly recurence', compute='_compute_graph', sanitize=False)
graph_day_of_month_recurence = fields.Html('Monthly recurence', compute='_compute_graph', sanitize=False)
@api.constrains('tags_min_version_id', 'tags_max_version_id') @api.constrains('tags_min_version_id', 'tags_max_version_id')
def _check_min_max_version(self): def _check_min_max_version(self):
for build_error in self: for build_error in self:
@ -263,6 +283,67 @@ class BuildError(models.Model):
else: else:
record.analogous_content_ids = False record.analogous_content_ids = False
def _get_log_dates(self, start_date: datetime.datetime, end_date: datetime.datetime):
"""
Returns an count of build_error per hour for the last 30 days.
-> Dict[Self, Dict[datetime, int]]
"""
assert self, 'Method does not work if called with empty recordset.'
result = defaultdict(dict)
if not self._origin.ids:
return result
self.env.cr.execute("""
SELECT error.id as error_id, date_trunc('hour', link.log_date) as time, count(*) as count
FROM runbot_build_error AS error
JOIN runbot_build_error_content AS content ON content.error_id = error.id
JOIN runbot_build_error_link AS link ON link.error_content_id = content.id
WHERE error.id IN %s AND link.log_date BETWEEN %s AND %s
GROUP BY error.id, date_trunc('hour', link.log_date)
""", (tuple(self.ids), start_date, end_date))
data = self.env.cr.dictfetchall()
for d in data:
result[self.browse(d['error_id'])][d['time']] = d['count']
return result
@api.depends('build_error_link_ids')
def _compute_graph(self):
end_date = fields.Date.today() + relativedelta(days=1)
start_date = end_date - relativedelta(days=30)
log_date_per_error = self._get_log_dates(start_date, end_date)
for error in self:
dates = log_date_per_error[error]
daily_freq = [
sum(
count
for hour, count in dates.items() if hour.date() == date.date()
)
for date in rrule.rrule(rrule.DAILY, dtstart=start_date, until=end_date)
]
error.graph_history = draw_svg(daily_freq, max_value=max(daily_freq))
hourly_freq = [
sum(
count
for hour, count in dates.items() if hour.hour == h
)
for h in range(24)
]
error.graph_hourly_recurence = draw_svg(hourly_freq)
day_of_week_freq = [
sum(
count
for hour, count in dates.items() if hour.isoweekday() -1 == day
)
for day in range(7)
]
error.graph_day_of_week_recurence = draw_svg(day_of_week_freq)
day_of_month_recurrence = [
sum(
count
for hour, count in dates.items() if hour.day - 1 == day
)
for day in range(31)
]
error.graph_day_of_month_recurence = draw_svg(day_of_month_recurrence)
@api.constrains('test_tags') @api.constrains('test_tags')
def _check_test_tags(self): def _check_test_tags(self):

View File

@ -48,6 +48,7 @@
<field name="random"/> <field name="random"/>
<field name="first_seen_date" widget="frontend_url" options="{'link_field': 'first_seen_build_id'}"/> <field name="first_seen_date" widget="frontend_url" options="{'link_field': 'first_seen_build_id'}"/>
<field name="last_seen_date" widget="frontend_url" options="{'link_field': 'last_seen_build_id'}"/> <field name="last_seen_date" widget="frontend_url" options="{'link_field': 'last_seen_build_id'}"/>
<field name="graph_history"/>
<field name="first_seen_build_id" invisible="True"/> <field name="first_seen_build_id" invisible="True"/>
<field name="last_seen_build_id" invisible="True"/> <field name="last_seen_build_id" invisible="True"/>
</group> </group>
@ -188,6 +189,18 @@
</list> </list>
</field> </field>
</page> </page>
<page string="Frequency graphs">
<group>
<group>
<field name="graph_history"/>
<field name="graph_hourly_recurence"/>
</group>
<group>
<field name="graph_day_of_week_recurence"/>
<field name="graph_day_of_month_recurence"/>
</group>
</group>
</page>
</notebook> </notebook>
</sheet> </sheet>
<chatter/> <chatter/>
@ -347,6 +360,11 @@
<field name="fixing_pr_id" optional="hide"/> <field name="fixing_pr_id" optional="hide"/>
<field name="fixing_pr_alive" optional="hide"/> <field name="fixing_pr_alive" optional="hide"/>
<field name="fixing_pr_url" widget="pull_request_url" text="view PR" readonly="1" invisible="not fixing_pr_url"/> <field name="fixing_pr_url" widget="pull_request_url" text="view PR" readonly="1" invisible="not fixing_pr_url"/>
<field name="graph_history" optional="show"/>
<field name="graph_hourly_recurence" optional="hide"/>
<field name="graph_day_of_week_recurence" optional="hide"/>
<field name="graph_day_of_month_recurence" optional="hide"/>
</list> </list>
</field> </field>
</record> </record>