From 56e8451d76171a5f64d8563fcaadcd30732bc416 Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Tue, 24 Dec 2024 15:34:27 +0100 Subject: [PATCH] --wip-- [skip ci] --- runbot/__manifest__.py | 8 + runbot/controllers/frontend.py | 156 ++++++--- runbot/models/batch.py | 4 + runbot/models/build.py | 19 ++ runbot/static/src/css/legacy.scss | 17 + runbot/static/src/css/red404.scss | 16 + runbot/static/src/css/runbot.scss | 312 +++++++++++++++++- .../src/js/manage_preferences_dialog.js | 124 +++++++ .../src/js/manage_preferences_dialog.xml | 62 ++++ runbot/static/src/js/runbot.js | 117 ++++++- runbot/templates/new/batch.xml | 214 ++++++++++++ runbot/templates/new/build.xml | 286 ++++++++++++++++ runbot/templates/new/bundle.xml | 138 ++++++++ runbot/templates/new/commit.xml | 121 +++++++ runbot/templates/new/page.xml | 301 +++++++++++++++++ runbot/templates/new/slot.xml | 21 ++ runbot/templates/utils.xml | 158 ++++----- runbot/views/repo_views.xml | 2 +- 18 files changed, 1941 insertions(+), 135 deletions(-) create mode 100644 runbot/static/src/js/manage_preferences_dialog.js create mode 100644 runbot/static/src/js/manage_preferences_dialog.xml create mode 100644 runbot/templates/new/batch.xml create mode 100644 runbot/templates/new/build.xml create mode 100644 runbot/templates/new/bundle.xml create mode 100644 runbot/templates/new/commit.xml create mode 100644 runbot/templates/new/page.xml create mode 100644 runbot/templates/new/slot.xml diff --git a/runbot/__manifest__.py b/runbot/__manifest__.py index 6690a92a..8e7e2a01 100644 --- a/runbot/__manifest__.py +++ b/runbot/__manifest__.py @@ -36,6 +36,12 @@ 'templates/git.xml', 'templates/nginx.xml', 'templates/build_error.xml', + 'templates/new/page.xml', + 'templates/new/bundle.xml', + 'templates/new/batch.xml', + 'templates/new/build.xml', + 'templates/new/slot.xml', + 'templates/new/commit.xml', 'views/branch_views.xml', 'views/build_error_views.xml', @@ -94,6 +100,8 @@ '/runbot/static/src/libs/bootstrap/js/bootstrap.bundle.js', '/runbot/static/src/js/runbot.js', + '/runbot/static/src/js/manage_preferences_dialog.js', + '/runbot/static/src/js/manage_preferences_dialog.xml', ], }, 'post_load': 'runbot_post_load', diff --git a/runbot/controllers/frontend.py b/runbot/controllers/frontend.py index a008ba98..0ca29892 100644 --- a/runbot/controllers/frontend.py +++ b/runbot/controllers/frontend.py @@ -3,6 +3,7 @@ import datetime import werkzeug import logging import functools +from typing import TypedDict, Optional, List, NamedTuple import werkzeug.utils import werkzeug.urls @@ -18,13 +19,39 @@ from odoo.osv import expression _logger = logging.getLogger(__name__) +class Breadcrumb(NamedTuple): + url: str + name: str + +Breadcrumbs = List[Breadcrumb] + +class ToolbarContext(TypedDict): + """ + Context used by 'runbot.layout_toolbar', should be provided through the 'toolbar' context key. + """ + sticky: Optional[bool] # Defines if the toolbar is sticky or not, defaults to true + + start_template: Optional[str] # Default to 'runbot.layout_toolbar_start_section' + # start_template default expected values + breadcrumbs: Optional[Breadcrumbs] + middle_template: Optional[str] # Defaults to 'runbot.layout_toolbar_middle_section' + # middle_template default expected values + message: Optional[str] + end_template: Optional[str] # Defaults to 'runbot.layout_toolbar_end_section' + # end_template expected values + pending_count: Optional[int] + pending_level: Optional[int] + pending_assigned_count: Optional[int] + # hosts_data: Optional[request.env['runbot.host']] + + def route(routes, **kw): def decorator(f): @o_route(routes, **kw) @functools.wraps(f) def response_wrap(*args, **kwargs): projects = request.env['runbot.project'].search([('hidden', '=', False)]) - more = request.httprequest.cookies.get('more', False) == '1' + more = request.httprequest.cookies.get('more', False) in ('1', 'true') filter_mode = request.httprequest.cookies.get('filter_mode', 'all') keep_search = request.httprequest.cookies.get('keep_search', False) == '1' cookie_search = request.httprequest.cookies.get('search', '') @@ -47,7 +74,6 @@ def route(routes, **kw): project = response.qcontext.get('project') or projects and projects[0] - response.qcontext['theme'] = kwargs.get('theme', request.httprequest.cookies.get('theme', 'legacy')) response.qcontext['projects'] = projects response.qcontext['more'] = more response.qcontext['keep_search'] = keep_search @@ -72,6 +98,22 @@ def route(routes, **kw): class Runbot(Controller): + def _get_default_toolbar(self, *, include_message=False): + pending_count, level, _, pending_assigned_count = self._pending() + + if include_message: + message = request.env['ir.config_parameter'].sudo().get_param('runbot.runbot_message') + else: + message = None + + return ToolbarContext( + pending_count=pending_count, + pending_level=level, + pending_assigned_count=pending_assigned_count, + hosts_data=request.env['runbot.host'].search([('assigned_only', '=', False)]), + message=message, + ) + def _pending(self): ICP = request.env['ir.config_parameter'].sudo().get_param warn = int(ICP('runbot.pending.warning', 5)) @@ -82,47 +124,21 @@ class Runbot(Controller): level = ['info', 'warning', 'danger'][int(pending_count > warn) + int(pending_count > crit)] return pending_count, level, scheduled_count, pending_assigned_count - @o_route([ - '/runbot/submit' - ], type='http', auth="public", methods=['GET', 'POST'], csrf=False) - def submit(self, more=False, redirect='/', keep_search=False, category=False, filter_mode=False, update_triggers=False, **kwargs): - assert redirect.startswith('/') - response = werkzeug.utils.redirect(redirect) - response.set_cookie('more', '1' if more else '0') - if update_triggers: - enabled_triggers = [] - project_id = int(update_triggers) - for key in kwargs.keys(): - if key.startswith('trigger_'): - enabled_triggers.append(key.replace('trigger_', '')) - - key = 'trigger_display_%s' % project_id - if len(request.env['runbot.trigger'].search([('project_id', '=', project_id)])) == len(enabled_triggers): - response.delete_cookie(key) - else: - response.set_cookie(key, '-'.join(enabled_triggers)) - return response - @route(['/', '/runbot', '/runbot/', '/runbot//search/'], website=True, auth='public', type='http') - def bundles(self, project=None, search='', projects=False, refresh=False, for_next_freeze=False, limit=40, has_pr=None, **kwargs): + def bundles(self, project=None, search='', projects=False, refresh=False, for_next_freeze=False, limit=40, has_pr=None, old=None, **kwargs): search = search if len(search) < 60 else search[:60] env = request.env categories = env['runbot.category'].search([]) if not project and projects: project = projects[0] - pending_count, level, scheduled_count, pending_assigned_count = self._pending() context = { 'categories': categories, 'search': search, - 'message': request.env['ir.config_parameter'].sudo().get_param('runbot.runbot_message'), - 'pending_count': pending_count, - 'pending_assigned_count': pending_assigned_count, - 'pending_level': level, - 'scheduled_count': scheduled_count, + 'toolbar': self._get_default_toolbar(include_message=True), 'hosts_data': request.env['runbot.host'].search([('assigned_only', '=', False)]), } if project: @@ -185,29 +201,33 @@ class Runbot(Controller): 'search': search, }) - context.update({'message': request.env['ir.config_parameter'].sudo().get_param('runbot.runbot_message')}) # request.is_frontend = False # remove inherit branding - return request.render('runbot.bundles', context) + return request.render(f'runbot.bundles{"_new" if not old else ""}', context) @route([ '/runbot/bundle/', '/runbot/bundle//page/', '/runbot/bundle/', ], website=True, auth='public', type='http', sitemap=False) - def bundle(self, bundle=None, page=1, limit=50, **kwargs): + def bundle(self, bundle=None, page=1, limit=50, old=None, **kwargs): if isinstance(bundle, str): bundle = request.env['runbot.bundle'].search([('name', '=', bundle)], limit=1, order='id') if not bundle: raise NotFound slug = request.env['ir.http']._slug return werkzeug.utils.redirect(f'/runbot/bundle/{slug(bundle)}') + if isinstance(limit, str): + limit = int(limit) domain = [('bundle_id', '=', bundle.id), ('hidden', '=', False)] batch_count = request.env['runbot.batch'].search_count(domain) pager = request.website.pager( url='/runbot/bundle/%s' % bundle.id, total=batch_count, page=page, - step=50, + step=limit, + url_args={ + 'limit': limit, + } ) batchs = request.env['runbot.batch'].search(domain, limit=limit, offset=pager.get('offset', 0), order='id desc') @@ -218,9 +238,16 @@ class Runbot(Controller): 'project': bundle.project_id, 'title': 'Bundle %s' % bundle.name, 'page_info_state': bundle.last_batch._get_global_result(), + 'toolbar': ToolbarContext( + breadcrumbs=[ + Breadcrumb('/runbot/%s' % request.env['ir.http']._slug(bundle.project_id), bundle.project_id.display_name), + Breadcrumb('/runbot/bundle/%s' % bundle.id, bundle.display_name), + ], + middle_template='runbot.bundle_toolbar_middle_section', + end_template='runbot.pager', + ) } - - return request.render('runbot.bundle', context) + return request.render(f'runbot.bundle{"_new" if not old else ""}', context) @o_route([ '/runbot/bundle//force', @@ -237,15 +264,25 @@ class Runbot(Controller): return werkzeug.utils.redirect('/runbot/batch/%s' % batch.id) @route(['/runbot/batch/'], website=True, auth='public', type='http', sitemap=False) - def batch(self, batch_id=None, **kwargs): + def batch(self, batch_id=None, old=None, **kwargs): batch = request.env['runbot.batch'].browse(batch_id) + bundle = batch.bundle_id + project = bundle.project_id context = { 'batch': batch, - 'project': batch.bundle_id.project_id, + 'project': project, 'title': 'Batch %s (%s)' % (batch.id, batch.bundle_id.name), 'page_info_state': batch._get_global_result(), + 'toolbar': ToolbarContext( + breadcrumbs=[ + Breadcrumb('/runbot/%s' % request.env['ir.http']._slug(project), project.display_name), + Breadcrumb('/runbot/bundle/%s' % bundle.id, bundle.display_name), + Breadcrumb('/runbot/bundle/batch/%s' % batch.id, batch.display_name), + ], + middle_template='runbot.batch_toolbar_middle_section', + ), } - return request.render('runbot.batch', context) + return request.render(f'runbot.batch{"_new" if not old else ""}', context) @o_route(['/runbot/batch/slot//build'], auth='user', type='http') def slot_create_build(self, slot=None, **kwargs): @@ -256,7 +293,7 @@ class Runbot(Controller): '/runbot/commit/', '/runbot/commit/' ], website=True, auth='public', type='http', sitemap=False) - def commit(self, commit=None, commit_hash=None, **kwargs): + def commit(self, commit=None, commit_hash=None, old=None, **kwargs): if commit_hash: commit = request.env['runbot.commit'].search([('name', '=like', f'{commit_hash}%')], limit=1) if not commit.exists(): @@ -275,9 +312,9 @@ class Runbot(Controller): 'reflogs': request.env['runbot.ref.log'].search([('commit_id', '=', commit.id)]), 'status_list': status_list, 'last_status_by_context': last_status_by_context, - 'title': 'Commit %s' % commit.name[:8] + 'title': 'Commit %s' % commit.name[:8], } - return request.render('runbot.commit', context) + return request.render(f'runbot.commit{"_new" if not old else ""}', context) @o_route(['/runbot/commit/resend/'], website=True, auth='user', type='http') def resend_status(self, status_id=None, **kwargs): @@ -313,7 +350,7 @@ class Runbot(Controller): '/runbot/build/', '/runbot/batch//build/' ], type='http', auth="public", website=True, sitemap=False) - def build(self, build_id, search=None, from_batch=None, **post): + def build(self, build_id, search=None, from_batch=None, old=None, **post): """Events/Logs""" if from_batch: @@ -329,10 +366,34 @@ class Runbot(Controller): if not build.exists(): return request.not_found() siblings = (build.parent_id.children_ids if build.parent_id else from_batch.slot_ids.build_id if from_batch else build).sorted('id') + project = build.params_id.trigger_id.project_id + breadcrumbs: Breadcrumbs = [Breadcrumb('/runbot/%s' % request.env['ir.http']._slug(project), project.display_name)] + batch = bundle = None + if from_batch: + batch = from_batch + batches = batch + bundle = batch.bundle_id + bundles = bundle + else: + batches = build.top_parent.with_context(active_test=False).slot_ids.batch_id + bundles = batches.bundle_id + if len(batches) == 1: + batch = batches + if len(bundles) == 1: + bundle = bundles + if bundle: + breadcrumbs.append(Breadcrumb(bundle._url(), bundle.display_name)) + if batch: + breadcrumbs.append(Breadcrumb(batch._url(), batch.display_name)) + breadcrumbs.extend( + Breadcrumb(ancestor.build_url, ancestor.description or ancestor.config_id.name) for ancestor in build.ancestors + ) context = { 'build': build, + 'batches': batches, + 'bundles': bundles, 'from_batch': from_batch, - 'project': build.params_id.trigger_id.project_id, + 'project': project, 'title': 'Build %s' % build.id, 'siblings': siblings, 'page_info_state': build.global_result, @@ -341,8 +402,13 @@ class Runbot(Controller): 'prev_bu': next((b for b in reversed(siblings) if b.id < build.id), Build), 'next_bu': next((b for b in siblings if b.id > build.id), Build), 'next_ko': next((b for b in siblings if b.id > build.id and b.global_result != 'ok'), Build), + 'toolbar': ToolbarContext( + breadcrumbs=breadcrumbs, + middle_template='runbot.build_toolbar_middle_section', + end_template='runbot.build_toolbar_end_section', + ) } - return request.render("runbot.build", context) + return request.render(f'runbot.build{"_new" if not old else ""}', context) @route([ '/runbot/build/search', diff --git a/runbot/models/batch.py b/runbot/models/batch.py index e617cc0f..7f3aeb35 100644 --- a/runbot/models/batch.py +++ b/runbot/models/batch.py @@ -34,6 +34,10 @@ class Batch(models.Model): column2='referenced_batch_id', ) + def _compute_display_name(self): + for batch in self: + batch.display_name = f'Batch #{batch.id}' + @api.depends('slot_ids.build_id') def _compute_all_build_ids(self): all_builds = self.env['runbot.build'].search([('id', 'child_of', self.slot_ids.build_id.ids)]) diff --git a/runbot/models/build.py b/runbot/models/build.py index 220d8812..3fa9ff02 100644 --- a/runbot/models/build.py +++ b/runbot/models/build.py @@ -1269,3 +1269,22 @@ class BuildResult(models.Model): def _parse_config(self): return set(findall(self._server("tools/config.py"), r'--[\w-]+', )) + + def _get_view_class(self): + """ + Returns the color class to use according to bootstrap (+ killed). + """ + self.ensure_one() + + if self.global_state in ('running', 'done'): + if self.global_result == 'ok': + return 'success' + elif self.global_result == 'skipped': + return 'skipped' + elif self.global_result in ('killed', 'manually_killed'): + return 'killed' + if self.global_result == 'ko': + return 'danger' + elif self.global_result == 'warn': + return 'warning' + return 'info' diff --git a/runbot/static/src/css/legacy.scss b/runbot/static/src/css/legacy.scss index 4b6e286f..fab22195 100644 --- a/runbot/static/src/css/legacy.scss +++ b/runbot/static/src/css/legacy.scss @@ -14,6 +14,10 @@ color: #fff !important; // It's black by default, color forced to fit with --bs-info-rgb } + .text-bg-success { + color: #000 !important; + } + .btn-success { --bs-btn-color: #fff; --bs-btn-bg: #198754; @@ -30,4 +34,17 @@ --bs-btn-disabled-bg: #28a745; --bs-btn-disabled-border-color: #28a745; } + + .o_runbot_batch_card > .card-header { + --bs-success-rgb: 223, 240, 216; + --bs-info-rgb: 217, 237, 247; + --bs-danger-rgb: 242, 222, 222; + --bs-warning-rgb: 255, 249, 230; + &.text-bg-info, &.text-bg-danger { + color: #000 !important; + } + .badge { + --bs-warning-rgb: 255, 193, 7; + } + } } diff --git a/runbot/static/src/css/red404.scss b/runbot/static/src/css/red404.scss index 67a58227..b674cce5 100644 --- a/runbot/static/src/css/red404.scss +++ b/runbot/static/src/css/red404.scss @@ -25,4 +25,20 @@ --bs-btn-disabled-bg: #b90e6c; --bs-btn-disabled-border-color: #b90e6c; } + + .o_runbot_batch_card > .card-header { + --bs-success-rgb: 205, 255, 185; + --bs-info-rgb: 182, 226, 248; + --bs-danger-rgb: 230, 126, 207; + --bs-warning-rgb: 250, 233, 177; + &.text-bg-danger { + color: #fff !important; + } + &.text-bg-success { + color: #000 !important; + } + .badge { + --bs-warning-rgb: 255, 193, 7; + } + } } diff --git a/runbot/static/src/css/runbot.scss b/runbot/static/src/css/runbot.scss index 427a4d93..8c4e9bce 100644 --- a/runbot/static/src/css/runbot.scss +++ b/runbot/static/src/css/runbot.scss @@ -23,7 +23,6 @@ --bs-btn-disabled-color: var(--btn-default-color); --bs-btn-disabled-bg: var(--bs-body-bg); --bs-btn-disabled-border-color: var(--btn-default-border); - ; } .btn-info { @@ -102,7 +101,7 @@ a { color: #005452; } - .slots_infos:hover { + &.slots_infos:hover { text-decoration: none; } } @@ -156,6 +155,10 @@ body, content: none; } +.dropdown-toggle-caret:after { + content: ' '; +} + .one_line { white-space: nowrap; overflow: hidden; @@ -183,12 +186,16 @@ body, margin-left: auto; } -.bg-killed { - background-color: #aaa; +.bg-killed, +.bg-killed-subtle, +.bg-skipped, +.bg-skipped-subtle { + background-color: var(--bs-tertiary-bg); } .text-bg-killed { - background-color: #aaa; + color: var(--bs-body-color) !important; + background-color: var(--bs-tertiary-bg); } .table-condensed td { @@ -343,3 +350,298 @@ body, code { white-space: pre-wrap; } + +/* NEW STUFF */ + +.o_runbot_sticky_star { + color: #f0ad4e; +} + +/* Layout */ +.o_runbot_main_container>.row { + padding: .5rem 0; +} + +.o_runbot_main_container>.row:not(:last-child) { + border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +/* Navbar */ +.o_runbot_navbar_username { + max-width: 16rem; +} + +.o_runbot_btn_haspr { + color: var(--bs-btn-color) !important; + background-color: var(--bs-btn-bg) !important; + border-color: var(--bs-btn-border-color) !important; +} + +.o_runbot_btn_haspr:has(input:checked) { + color: var(--bs-btn-active-color) !important; + background-color: var(--bs-btn-active-bg) !important; + border-color: var(--bs-btn-active-border-color) !important; +} + +/* Bundles */ +@media (min-width: 768px) { + .o_runbot_bundle_info { + width: 16rem; + max-width: 16rem; + } +} + +.o_runbot_bundle_row .o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_slots>.o_runbot_batch_card_slot { + min-width: 50%; + flex-grow: 1; +} + +@media (min-width: 1400px) { + .o_runbot_bundle_row .o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_slots>.o_runbot_batch_card_slot:not(.o_runbot_batch_card_slot_preparing) { + max-width: 50%; + } +} + +@media (max-width: 576px) { + .o_runbot_bundle .o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_slots>.o_runbot_batch_card_slot { + min-width: 50%; + flex-grow: 1; + } +} + +@media (min-width: 576px) { + .o_runbot_bundle .o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_slots>.o_runbot_batch_card_slot { + flex-basis: 200px; + flex-grow: 1; + max-width: 25%; + } +} + +@media (min-width: 992px) { + .o_runbot_bundle .o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_slots>.o_runbot_batch_card_slot { + flex-basis: 200px; + flex-grow: 1; + max-width: 20%; + min-width: 12.5%; + } +} + +.o_runbot_bundle>.col-12 { + --bs-gutter-x: 0.5rem; + --bs-gutter-y: 0.5rem; +} + +/* Batch */ +:root[data-bs-theme="legacy"] .o_runbot_batch_card { + /* In legacy theme we want to keep the card's border color */ + border: var(--bs-card-border-width) solid var(--bs-card-border-color) !important; +} + +.o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_slots>.o_runbot_batch_card_slot>.o_runbot_slot_btn_group { + width: 100%; +} + +.o_runbot_batch_card>.o_runbot_batch_card_content>.o_runbot_batch_card_commits { + font-size: 80%; +} + +.o_runbot_batch>.o_runbot_batch_slots .o_runbot_slot_btn_group { + width: 100%; +} + +/* Slot */ +// .btn-group-ssm > * { +// align-items: center; +// } +.btn-group-ssm { + >* { + align-content: center; + } + + .o_runbot_slot_btn_small, + .o_runbot_build_menu { + flex: 0 0 25px; + } +} + +.o_runbot_slot_btn_group .o_runbot_slot_btn_small, +.o_runbot_slot_btn_group>.o_runbot_build_menu { + flex: 0 0 25px; +} + +.o_runbot_grid { + // Max width for regular columns + --runbot-grid-fit-content-width: 200px; + + display: grid; + line-height: normal; + min-width: 0; + grid-template-columns: ( + repeat(calc(var(--runbot-grid-columns) - 1), fit-content(var(--runbot-grid-fit-content-width))) + 1fr + ); + + &.o_runbot_grid_auto { + grid-template-columns: repeat(var(--runbot-grid-columns), 1fr); + } + + &>* { + padding: 0.25rem; + } + + &>.separator { + grid-column: span var(--runbot-grid-columns); + } + + &>*:not(.separator) { + // Same as .border-bottom; + border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; + } + + &>.o_runbot_grid_full_row { + grid-column: span var(--runbot-grid-columns); + } +} + +@for $size from 2 through 5 { + .o_runbot_grid_#{$size} { + --runbot-grid-columns: #{$size}; + + &>*:nth-last-child(-n + #{$size}) { + border-bottom: none !important; + } + + &>*:not(:nth-child(#{$size}n+1)) { + border-left: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; + } + } +} + +.o_runbot_commit_status_grid { + grid-template-columns: fit-content(var(--runbot-grid-fit-content-width)) 1fr repeat(3, fit-content(var(--runbot-grid-fit-content-width))); +} + +.o_runbot_commit_status_history_grid { + grid-template-columns: fit-content(var(--runbot-grid-fit-content-width)) 1fr repeat(2, fit-content(var(--runbot-grid-fit-content-width))); +} + +.o_runbot_log_grid { + display: grid; + grid-template-columns: repeat(3, fit-content(200px)) 1fr; + line-height: normal; + min-width: 0; + + &>*:not(.separator) { + // Same as .border-bottom; + border-bottom: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; + } + + &>.separator { + grid-column: span 4; + } + + &>.o_runbot_log_grid_header { + font-weight: bold; + padding: 0.5rem 0; + } + + &>.o_runbot_log_grid_date { + padding-left: 0.5rem; + } + + &>*:not(.o_runbot_log_grid_message) { + padding-right: 0.5rem; + } + + &>.o_runbot_log_grid_message { + overflow: hidden; + + &>* { + overflow: auto; + text-wrap: nowrap; + } + + .o_runbot_log_message_toolbox { + display: flex; + position: sticky; + left: 0; + width: fit-content; + margin-top: 0.5rem; + opacity: 0.8; + + &:hover { + opacity: 1; + + // .btn.disabled has a different opacity + &>* { + --bs-btn-disabled-opacity: 1; + } + } + } + } +} + +/* TODO: remove if not used */ +.bg-danger-subtle-hover:hover { + background-color: var(--bs-danger) !important; + color: #fff !important; +} + +.bg-success-subtle-hover:hover { + background-color: var(--bs-success) !important; + color: #fff !important; +} + +.bg-warning-subtle-hover:hover { + background-color: var(--bs-warning) !important; + color: #000 !important; +} + +.bg-info-subtle-hover:hover { + background-color: var(--bs-info) !important; + color: #000 !important; +} + +.bg-killed-subtle-hover:hover { + background-color: var(--body-secondary-bg); + color: var(--body-secondary-color); +} + + +@media (min-width: 768px) { + .w-md-auto { + width: auto !important; + } +} + +.modal:not([data-bs-backdrop="false"]) { + background-color: rgba(0, 0, 0, 0.5); +} + +.fa-bounce { + animation: bounceIn 2s 1; +} + +@keyframes bounceIn { + 0% { + transform: scale(1) translateY(0); + } + // 10% { + // transform: translateY(0); + // } + // 30% { + // transform: translateY(var(--fa-bounce-height, -.5em)); + // } + 50% { + transform: scale(1.1) translateY(0); + } + // 57% { + // transform: scale(1) translateY(var(--fa-bounce-rebound, -.125em)); + // } + // 64% { + // transform: scale(1) translateY(0); + // } + 100% { + transform: scale(1) translateY(0); + } +} diff --git a/runbot/static/src/js/manage_preferences_dialog.js b/runbot/static/src/js/manage_preferences_dialog.js new file mode 100644 index 00000000..f6fff143 --- /dev/null +++ b/runbot/static/src/js/manage_preferences_dialog.js @@ -0,0 +1,124 @@ +import { Dialog } from "@web/core/dialog/dialog"; +import { cookie } from "@web/core/browser/cookie"; +import { useService } from "@web/core/utils/hooks"; + +import { Component, useState, onWillStart, onWillRender } from "@odoo/owl"; + + +export class ManagePreferencesDialog extends Component { + static components = { Dialog }; + static template = 'runbot.ManagePreferencesDialog'; + + setup() { + const projectId = document.head.querySelector('[name=runbot-project-id]').content; + this.projectId = projectId && Number(projectId); + this.filterModes = [ + {value: 'all', label: 'All'}, + {value: 'sticky', label: 'Sticky only'}, + {value: 'nosticky', label: 'Dev only'}, + ] + this.originalState = { + filter_mode: cookie.get('filter_mode') || 'all', + category: Number(cookie.get('category') || '1'), + } + this.dirtyTriggers = false; + this.clearTriggerCookie = false; + this.orm = useService('orm'); + + this.state = useState({ + categories: [], + categoryById: {}, + triggersByCategory: {}, + + selectedCategory: this.originalState.category, + selectedFilterMode: this.originalState.filter_mode, + }); + + onWillStart(async () => { + this.state.categories = await this.orm.searchRead( + 'runbot.category', + [], + ['id', 'name'], + ); + this.state.categoryById = Object.fromEntries( + this.state.categories.map(c => [c.id, c]) + ); + const triggers = await this.orm.searchRead( + 'runbot.trigger', + [['project_id', '=', this.projectId || false]], + ['id', 'name', 'category_id', 'hide'], + ); + this.state.triggersByCategory = triggers.reduce( + (agg, trigger) => { + if (!agg[trigger.category_id[0]]) { + agg[trigger.category_id[0]] = [] + } + agg[trigger.category_id[0]].push(trigger); + return agg; + }, {} + ); + const activeTriggerCookie = cookie.get(`trigger_display_${this.projectId}`); + const activeTriggersFromCookies = activeTriggerCookie && ( + activeTriggerCookie.split('-').map(Number) + ); + Array.from(Object.values(this.state.triggersByCategory)).flat().forEach( + trigger => { + trigger.active = activeTriggersFromCookies ? activeTriggersFromCookies.includes(trigger.id) : !trigger.hide; + } + ); + }); + } + + get _allTriggers() { + return Array.from(Object.values(this.state.triggersByCategory)).flat(); + } + + save() { + const cookieName = `trigger_display_${this.projectId}`; + if (this.clearTriggerCookie) { + cookie.delete(cookieName); + } else if (this.dirtyTriggers) { + cookie.set(cookieName, this._computeTriggerCookie()); + } + cookie.set('filter_mode', this.state.selectedFilterMode); + cookie.set('category', this.state.selectedCategory); + location.reload(); + } + + _computeTriggerCookie() { + return this._allTriggers.filter(t => t.active).map(({ id }) => id).sort().join('-'); + } + + selectFilterMode({ value }) { + this.state.selectedFilterMode = value; + } + + selectCategory({ id }) { + this.state.selectedCategory = id; + } + + toggleTrigger(trigger) { + trigger.active = !trigger.active; + this.dirtyTriggers = true; + this.clearTriggerCookie = false; + } + + resetTriggers() { + this._allTriggers.forEach( + trigger => trigger.active = !trigger.hide + ); + this.clearTriggerCookie = true; + } + + allTriggers() { + this._allTriggers.forEach(t => t.active = true); + this.dirtyTriggers = true; + this.clearTriggerCookie = false; + } + + noTriggers() { + this._allTriggers.forEach(t => t.active = false); + this.dirtyTriggers = true; + this.clearTriggerCookie = false; + } +} diff --git a/runbot/static/src/js/manage_preferences_dialog.xml b/runbot/static/src/js/manage_preferences_dialog.xml new file mode 100644 index 00000000..441bf7e7 --- /dev/null +++ b/runbot/static/src/js/manage_preferences_dialog.xml @@ -0,0 +1,62 @@ + + + + + +
+
+ + + +
+
+ +
+
+
+
+
+ +
+ + + +
+
+
+ +
+ + + +
+
+ +
+

Triggers:

+
+ +
+ +
+
+
+
+
+
+
+
+
diff --git a/runbot/static/src/js/runbot.js b/runbot/static/src/js/runbot.js index ec396602..34b73656 100644 --- a/runbot/static/src/js/runbot.js +++ b/runbot/static/src/js/runbot.js @@ -1,4 +1,8 @@ import publicWidget from "@web/legacy/js/public/public_widget"; +import { debounce } from "@web/core/utils/timing"; +import { cookie } from "@web/core/browser/cookie"; +import { ManagePreferencesDialog } from "@runbot/js/manage_preferences_dialog"; +// import { FormErrorDialog } from "@web/views/form/form_error_dialog/form_error_dialog"; publicWidget.registry.RunbotPage = publicWidget.Widget.extend({ @@ -7,18 +11,29 @@ publicWidget.registry.RunbotPage = publicWidget.Widget.extend({ events: { 'click [data-runbot]': '_onClickDataRunbot', 'click [data-runbot-clipboard]': '_onClickRunbotCopy', + 'click .o_runbot_copy_link': '_onClickCopyLink', + }, + + start: function () { + this._super(...arguments); + + // If we have a hash, try to animate the hashed id + const hash = window.location.hash.substring(1); + if (hash.length) { + const elem = document.getElementById(hash); + if (elem) { + elem.classList.add('fa-bounce', 'text-bg-warning'); + } + } }, _onClickDataRunbot: async (event) => { const { currentTarget: target } = event; - if (!target) { - return; - } - event.preventDefault(); const { runbot: operation, runbotBuild } = target.dataset; if (!operation) { return; } + event.preventDefault(); let url = target.href; if (runbotBuild) { url = `/runbot/build/${runbotBuild}/${operation}` @@ -35,12 +50,100 @@ publicWidget.registry.RunbotPage = publicWidget.Widget.extend({ } }, - _onClickRunbotCopy: ({ currentTarget: target }) => { - if (!navigator.clipboard || !target) { + _writeClipboard: function (text) { + return navigator.clipboard.writeText(text); + }, + + _onClickRunbotCopy: function ({ currentTarget: target }) { + if (!navigator.clipboard) { return; } - navigator.clipboard.writeText( + this._writeClipboard( target.dataset.runbotClipboard ); + }, + + _onClickCopyLink: function (event) { + if (event.altKey || event.ctrlKey || event.metaKey) { + return; + } + const { currentTarget: target } = event; + // Check meta keys and stuff + event.preventDefault(); + this._writeClipboard(target.href); + } +}); + +// Set initial theme on page load +document.documentElement.dataset.bsTheme = localStorage.getItem('runbotTheme') || 'light'; + +publicWidget.registry.ThemeSwitcher = publicWidget.Widget.extend({ + selector: '.o_runbot_preferences', + events: { + 'change .o_runbot_theme_switcher': '_onChangeTheme', + 'click .o_runbot_more_info': '_onChangeMoreInfo', + 'click .o_runbot_manage_filters': '_onClickManageFilters', + }, + + init: function () { + this._super(...arguments); + this.theme = localStorage.getItem('runbotTheme') || 'light'; + document.documentElement.dataset.bsTheme = this.theme; + this._onChangeMoreInfo = debounce(this._onChangeMoreInfo, 300).bind(this); + }, + + start: function () { + this.moreInfoEl = this.el.querySelector('.o_runbot_more_info'); + this.dropdownMenu = this.el.querySelector('.dropdown-menu'); + this.el.querySelector('.o_runbot_theme_switcher').value = this.theme; + }, + + _onChangeTheme: ({ currentTarget: target }) => { + this.theme = target.value; + document.documentElement.dataset.bsTheme = this.theme; + localStorage.setItem('runbotTheme', this.theme); + }, + + _onChangeMoreInfo: function () { + const { checked } = this.moreInfoEl; + const cookieChecked = cookie.get('more'); + if (checked && !cookieChecked) { + cookie.set('more', 'true'); + } else if (!checked && cookieChecked) { + cookie.delete('more'); + } else { + return; + } + location.reload(); + }, + + _onClickManageFilters: function (event) { + event.preventDefault(); + this.dropdownMenu.classList.remove('show'); + this.call('dialog', 'add', ManagePreferencesDialog); + }, +}); + +publicWidget.registry.RunbotToolbar = publicWidget.Widget.extend({ + selector: '.o_runbot_toolbar.position-sticky', + + start: function () { + this._super(); + + const navbarElem = document.querySelector('nav.navbar'); + if (!navbarElem) { + return; + } + this.resizeObserver = new ResizeObserver(() => { + this.el.style.top = navbarElem.getBoundingClientRect().height; + }); + this.resizeObserver.observe(this.el); + }, + + destroy: function () { + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + } + this._super(); } }); diff --git a/runbot/templates/new/batch.xml b/runbot/templates/new/batch.xml new file mode 100644 index 00000000..f5f68a01 --- /dev/null +++ b/runbot/templates/new/batch.xml @@ -0,0 +1,214 @@ + + + + + + + + + + diff --git a/runbot/templates/new/build.xml b/runbot/templates/new/build.xml new file mode 100644 index 00000000..23fe8664 --- /dev/null +++ b/runbot/templates/new/build.xml @@ -0,0 +1,286 @@ + + + + + + + + + + diff --git a/runbot/templates/new/bundle.xml b/runbot/templates/new/bundle.xml new file mode 100644 index 00000000..834465a0 --- /dev/null +++ b/runbot/templates/new/bundle.xml @@ -0,0 +1,138 @@ + + + + + + + + + + diff --git a/runbot/templates/new/commit.xml b/runbot/templates/new/commit.xml new file mode 100644 index 00000000..ecb4bbf8 --- /dev/null +++ b/runbot/templates/new/commit.xml @@ -0,0 +1,121 @@ + + + + + + diff --git a/runbot/templates/new/page.xml b/runbot/templates/new/page.xml new file mode 100644 index 00000000..1fa2fa34 --- /dev/null +++ b/runbot/templates/new/page.xml @@ -0,0 +1,301 @@ + + + + + + +