runbot/runbot/controllers/frontend.py
Xavier-Do 02d2cc4528 [WIP] runbot: monitoring tools
Add a new model runbot.host to keep info and configuration about
hosts (worker servers), like number of worker, reserved or not,
ping times (last start loop, successful iteration, end loop, ...)
and also last errors, number of testing per host, psql connection
count, ...

A new monitoring frontend page is created, similar to glances
but with additionnal information like hosts states and
last_monitored builds (for nightly)

Later this model will be used for runbot_build host instead of char.

Host are automaticaly created when running _scheduler.
2019-09-03 11:10:38 +02:00

359 lines
16 KiB
Python

# -*- coding: utf-8 -*-
import operator
import werkzeug
from collections import OrderedDict
from odoo.addons.http_routing.models.ir_http import slug
from odoo.addons.website.controllers.main import QueryURL
from odoo.http import Controller, request, route
from ..common import uniq_list, flatten, fqdn
from odoo.osv import expression
class Runbot(Controller):
def _pending(self):
ICP = request.env['ir.config_parameter'].sudo().get_param
warn = int(ICP('runbot.pending.warning', 5))
crit = int(ICP('runbot.pending.critical', 12))
count = request.env['runbot.build'].search_count([('local_state', '=', 'pending')])
level = ['info', 'warning', 'danger'][int(count > warn) + int(count > crit)]
return count, level
@route(['/runbot', '/runbot/repo/<model("runbot.repo"):repo>'], website=True, auth='public', type='http')
def repo(self, repo=None, search='', refresh='', **kwargs):
search = search if len(search) < 60 else search[:60]
branch_obj = request.env['runbot.branch']
build_obj = request.env['runbot.build']
repo_obj = request.env['runbot.repo']
repo_ids = repo_obj.search([])
repos = repo_obj.browse(repo_ids)
if not repo and repos:
repo = repos[0].id
pending = self._pending()
context = {
'repos': repos.ids,
'repo': repo,
'host_stats': [],
'pending_total': pending[0],
'pending_level': pending[1],
'search': search,
'refresh': refresh,
}
build_ids = []
if repo:
domain = [('repo_id', '=', repo.id)]
if search:
search_domain = []
for to_search in search.split("|"):
search_domain = ['|', '|', '|'] + search_domain
search_domain += [('dest', 'ilike', to_search), ('subject', 'ilike', to_search), ('branch_id.branch_name', 'ilike', to_search)]
domain += search_domain[1:]
domain = expression.AND([domain, [('hidden', '=', False)]]) # don't display children builds on repo view
build_ids = build_obj.search(domain, limit=100)
branch_ids, build_by_branch_ids = [], {}
if build_ids:
branch_query = """
SELECT br.id FROM runbot_branch br INNER JOIN runbot_build bu ON br.id=bu.branch_id WHERE bu.id in %s
ORDER BY bu.sequence DESC
"""
sticky_dom = [('repo_id', '=', repo.id), ('sticky', '=', True)]
sticky_branch_ids = [] if search else branch_obj.search(sticky_dom).sorted(key=lambda b: (b.branch_name == 'master', b.id), reverse=True).ids
request._cr.execute(branch_query, (tuple(build_ids.ids),))
branch_ids = uniq_list(sticky_branch_ids + [br[0] for br in request._cr.fetchall()])
build_query = """
SELECT
branch_id,
max(case when br_bu.row = 1 then br_bu.build_id end),
max(case when br_bu.row = 2 then br_bu.build_id end),
max(case when br_bu.row = 3 then br_bu.build_id end),
max(case when br_bu.row = 4 then br_bu.build_id end)
FROM (
SELECT
br.id AS branch_id,
bu.id AS build_id,
row_number() OVER (PARTITION BY branch_id) AS row
FROM
runbot_branch br INNER JOIN runbot_build bu ON br.id=bu.branch_id
WHERE
br.id in %s AND (bu.hidden = 'f' OR bu.hidden IS NULL)
GROUP BY br.id, bu.id
ORDER BY br.id, bu.id DESC
) AS br_bu
WHERE
row <= 4
GROUP BY br_bu.branch_id;
"""
request._cr.execute(build_query, (tuple(branch_ids),))
build_by_branch_ids = {
rec[0]: [r for r in rec[1:] if r is not None] for rec in request._cr.fetchall()
}
branches = branch_obj.browse(branch_ids)
build_ids = flatten(build_by_branch_ids.values())
build_dict = {build.id: build for build in build_obj.browse(build_ids)}
def branch_info(branch):
return {
'branch': branch,
'fqdn': fqdn(),
'builds': [build_dict[build_id] for build_id in build_by_branch_ids.get(branch.id) or []]
}
context.update({
'branches': [branch_info(b) for b in branches],
'testing': build_obj.search_count([('repo_id', '=', repo.id), ('local_state', '=', 'testing')]),
'running': build_obj.search_count([('repo_id', '=', repo.id), ('local_state', '=', 'running')]),
'pending': build_obj.search_count([('repo_id', '=', repo.id), ('local_state', '=', 'pending')]),
'qu': QueryURL('/runbot/repo/' + slug(repo), search=search, refresh=refresh),
'fqdn': fqdn(),
})
# consider host gone if no build in last 100
build_threshold = max(build_ids or [0]) - 100
for result in build_obj.read_group([('id', '>', build_threshold)], ['host'], ['host']):
if result['host']:
context['host_stats'].append({
'host': result['host'],
'testing': build_obj.search_count([('local_state', '=', 'testing'), ('host', '=', result['host'])]),
'running': build_obj.search_count([('local_state', '=', 'running'), ('host', '=', result['host'])]),
})
context.update({'message': request.env['ir.config_parameter'].sudo().get_param('runbot.runbot_message')})
return request.render('runbot.repo', context)
@route(['/runbot/build/<int:build_id>/kill'], type='http', auth="user", methods=['POST'], csrf=False)
def build_ask_kill(self, build_id, search=None, **post):
build = request.env['runbot.build'].sudo().browse(build_id)
build._ask_kill()
return werkzeug.utils.redirect('/runbot/repo/%s' % build.repo_id.id + ('?search=%s' % search if search else ''))
@route(['/runbot/build/<int:build_id>/wakeup'], type='http', auth="user", methods=['POST'], csrf=False)
def build_wake_up(self, build_id, search=None, **post):
build = request.env['runbot.build'].sudo().browse(build_id)
build._wake_up()
return werkzeug.utils.redirect('/runbot/repo/%s' % build.repo_id.id + ('?search=%s' % search if search else ''))
@route([
'/runbot/build/<int:build_id>/force',
'/runbot/build/<int:build_id>/force/<int:exact>',
], type='http', auth="public", methods=['POST'], csrf=False)
def build_force(self, build_id, exact=0, search=None, **post):
build = request.env['runbot.build'].sudo().browse(build_id)
build._force(exact=bool(exact))
return werkzeug.utils.redirect('/runbot/repo/%s' % build.repo_id.id + ('?search=%s' % search if search else ''))
@route(['/runbot/build/<int:build_id>'], type='http', auth="public", website=True)
def build(self, build_id, search=None, **post):
"""Events/Logs"""
Build = request.env['runbot.build']
Logging = request.env['ir.logging']
build = Build.browse([build_id])[0]
if not build.exists():
return request.not_found()
# other builds
build_ids = Build.search([('branch_id', '=', build.branch_id.id)], limit=100)
other_builds = Build.browse(build_ids)
domain = [('build_id', '=', build.real_build.id)]
log_type = request.params.get('type', '')
if log_type:
domain.append(('type', '=', log_type))
level = request.params.get('level', '')
if level:
domain.append(('level', '=', level.upper()))
if search:
domain.append(('message', 'ilike', search))
logging_ids = Logging.sudo().search(domain, limit=10000)
context = {
'repo': build.repo_id,
'build': build,
'fqdn': fqdn(),
'br': {'branch': build.branch_id},
'logs': Logging.sudo().browse(logging_ids).ids,
'other_builds': other_builds.ids,
'bu_index': 0 if build == build_ids[0] else -1
}
return request.render("runbot.build", context)
@route(['/runbot/quick_connect/<model("runbot.branch"):branch>'], type='http', auth="public", website=True)
def fast_launch(self, branch, **post):
"""Connect to the running Odoo instance"""
Build = request.env['runbot.build']
domain = [('branch_id', '=', branch.id), ('config_id', '=', branch.config_id.id)]
# Take the 10 lasts builds to find at least 1 running... Else no luck
builds = Build.search(domain, order='sequence desc', limit=10)
if builds:
last_build = False
for build in builds:
if build.real_build.local_state == 'running':
last_build = build.real_build
break
if not last_build:
# Find the last build regardless the state to propose a rebuild
last_build = builds[0]
if last_build.local_state != 'running':
url = "/runbot/build/%s?ask_rebuild=1" % last_build.id
else:
url = "http://%s/web/login?db=%s-all&login=admin&redirect=/web?debug=1" % (last_build.domain, last_build.dest)
else:
return request.not_found()
return werkzeug.utils.redirect(url)
@route(['/runbot/dashboard'], type='http', auth="public", website=True)
def dashboard(self, refresh=None):
cr = request.cr
RB = request.env['runbot.build']
repos = request.env['runbot.repo'].search([]) # respect record rules
cr.execute("""SELECT bu.id
FROM runbot_branch br
JOIN LATERAL (SELECT *
FROM runbot_build bu
WHERE bu.branch_id = br.id
ORDER BY id DESC
LIMIT 3
) bu ON (true)
JOIN runbot_repo r ON (r.id = br.repo_id)
WHERE br.sticky
AND br.repo_id in %s
ORDER BY r.sequence, r.name, br.branch_name, bu.id DESC
""", [tuple(repos._ids)])
builds = RB.browse(map(operator.itemgetter(0), cr.fetchall()))
count = RB.search_count
pending = self._pending()
qctx = {
'refresh': refresh,
'host_stats': [],
'pending_total': pending[0],
'pending_level': pending[1],
}
repos_values = qctx['repo_dict'] = OrderedDict()
for build in builds:
repo = build.repo_id
branch = build.branch_id
r = repos_values.setdefault(repo.id, {'branches': OrderedDict()})
if 'name' not in r:
r.update({
'name': repo.name,
'base': repo.base,
'testing': count([('repo_id', '=', repo.id), ('local_state', '=', 'testing')]),
'running': count([('repo_id', '=', repo.id), ('local_state', '=', 'running')]),
'pending': count([('repo_id', '=', repo.id), ('local_state', '=', 'pending')]),
})
b = r['branches'].setdefault(branch.id, {'name': branch.branch_name, 'builds': list()})
b['builds'].append(build)
# consider host gone if no build in last 100
build_threshold = max(builds.ids or [0]) - 100
for result in RB.read_group([('id', '>', build_threshold)], ['host'], ['host']):
if result['host']:
qctx['host_stats'].append({
'fqdn': fqdn(),
'host': result['host'],
'testing': count([('local_state', '=', 'testing'), ('host', '=', result['host'])]),
'running': count([('local_state', '=', 'running'), ('host', '=', result['host'])]),
})
return request.render("runbot.sticky-dashboard", qctx)
def _glances_ctx(self):
repos = request.env['runbot.repo'].search([]) # respect record rules
default_config_id = request.env.ref('runbot.runbot_build_config_default').id
query = """
SELECT split_part(r.name, ':', 2),
br.branch_name,
(array_agg(bu.global_result order by bu.id desc))[1]
FROM runbot_build bu
JOIN runbot_branch br on (br.id = bu.branch_id)
JOIN runbot_repo r on (r.id = br.repo_id)
WHERE br.sticky
AND br.repo_id in %s
AND (bu.hidden = 'f' OR bu.hidden IS NULL)
AND (
bu.global_state in ('running', 'done')
)
AND bu.global_result not in ('skipped', 'manually_killed')
AND (bu.config_id = r.repo_config_id
OR bu.config_id = br.branch_config_id
OR bu.config_id = %s)
GROUP BY 1,2,r.sequence,br.id
ORDER BY r.sequence, (br.branch_name='master'), br.id
"""
cr = request.env.cr
cr.execute(query, (tuple(repos.ids), default_config_id))
ctx = OrderedDict()
for row in cr.fetchall():
ctx.setdefault(row[0], []).append(row[1:])
return ctx
@route('/runbot/glances', type='http', auth='public', website=True)
def glances(self, refresh=None):
glances_ctx = self._glances_ctx()
pending = self._pending()
qctx = {
'refresh': refresh,
'pending_total': pending[0],
'pending_level': pending[1],
'glances_data': glances_ctx,
}
return request.render("runbot.glances", qctx)
@route('/runbot/monitoring', type='http', auth='user', website=True)
def monitoring(self, refresh=None):
glances_ctx = self._glances_ctx()
pending = self._pending()
hosts_data = request.env['runbot.host'].search([])
monitored_config_id = int(request.env['ir.config_parameter'].sudo().get_param('runbot.monitored_config_id', 1))
request.env.cr.execute("""SELECT DISTINCT ON (branch_id) branch_id, id FROM runbot_build
WHERE config_id = %s
AND global_state in ('running', 'done')
AND branch_id in (SELECT id FROM runbot_branch where sticky='t')
ORDER BY branch_id ASC, id DESC""", [int(monitored_config_id)])
last_monitored = request.env['runbot.build'].browse([r[1] for r in request.env.cr.fetchall()])
qctx = {
'refresh': refresh,
'pending_total': pending[0],
'pending_level': pending[1],
'glances_data': glances_ctx,
'hosts_data': hosts_data,
'last_monitored': last_monitored # nightly
}
return request.render("runbot.monitoring", qctx)
@route(['/runbot/branch/<int:branch_id>', '/runbot/branch/<int:branch_id>/page/<int:page>'], website=True, auth='public', type='http')
def branch_builds(self, branch_id=None, search='', page=1, limit=50, refresh='', **kwargs):
""" list builds of a runbot branch """
domain =[('branch_id','=',branch_id), ('hidden', '=', False)]
builds_count = request.env['runbot.build'].search_count(domain)
pager = request.website.pager(
url='/runbot/branch/%s' % branch_id,
total=builds_count,
page=page,
step=50
)
builds = request.env['runbot.build'].search(domain, limit=limit, offset=pager.get('offset',0))
context = {'pager': pager, 'builds': builds}
return request.render("runbot.branch", context)