From 6cc9a6ca11ebe1bf6c038a985257f1ee24df7d08 Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Wed, 24 Jul 2024 12:36:05 +0200 Subject: [PATCH] [IMP] runbot_merge: show batch inconsistency in PR dash picture Extract current table generation into a separate function, add an other function to render an alert / list of PR targets if the batch is not consistent. This means an extra pass on the table contents to precompute the image size, but we can delay loading fonts until after etag computation which might be a bigger gain all things considered: there aren't many cells in most PR tables, but fonts are rather expensive to load (I should probably load them at import and cache them in the module...) --- runbot_merge/controllers/dashboard.py | 120 +++++++++++++++++--------- 1 file changed, 80 insertions(+), 40 deletions(-) diff --git a/runbot_merge/controllers/dashboard.py b/runbot_merge/controllers/dashboard.py index 6a5dd2de..7c5f1f66 100644 --- a/runbot_merge/controllers/dashboard.py +++ b/runbot_merge/controllers/dashboard.py @@ -170,13 +170,6 @@ def raster_render(pr): if request.httprequest.headers.get('If-Modified-Since') == last_modified: return werkzeug.wrappers.Response(status=304, headers=headers) - with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf', 'rb') as f: - font = ImageFont.truetype(f, size=16, layout_engine=0) - f.seek(0) - supfont = ImageFont.truetype(f, size=13, layout_engine=0) - with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Bold.ttf', 'rb') as f: - bold = ImageFont.truetype(f, size=16, layout_engine=0) - batches = pr.env.ref('runbot_merge.dashboard-prep')._run_action_code_multi({ 'pr': pr, 'repos': repos, @@ -184,31 +177,48 @@ def raster_render(pr): 'genealogy': genealogy, }) - # getbbox returns (left, top, right, bottom) - - rows = {b: font.getbbox(b.name)[3] for b in branches} - rows[None] = max(bold.getbbox(r.name)[3] for r in repos) - - columns = {r: bold.getbbox(r.name)[2] for r in repos} - columns[None] = max(font.getbbox(b.name)[2] for b in branches) - - etag = hashlib.sha256(f"(P){pr.id},{pr.repository.id},{pr.target.id}".encode()) + etag = hashlib.sha256(f"(P){pr.id},{pr.repository.id},{pr.target.id},{pr.batch_id.blocked}".encode()) # repos and branches should be in a consistent order so can just hash that etag.update(''.join(f'(R){r.name}' for r in repos).encode()) etag.update(''.join(f'(T){b.name},{b.active}' for b in branches).encode()) # and product of deterministic iterations should be deterministic for r, b in product(repos, branches): ps = batches[r, b] - etag.update(f"(B){ps['state']},{ps['detached']},{ps['active']}".encode()) - # technically label (state + blocked) does not actually impact image - # render (though subcomponents of state do) however blocked is useful - # to force an etag miss so keeping it + etag.update(f"(B){ps['state']},{ps['detached']},{ps['active']}".encode()) etag.update(''.join( f"(PS){p['label']},{p['closed']},{p['number']},{p['checked']},{p['reviewed']},{p['attached']},{p['pr'].staging_id.id}" for p in ps['prs'] ).encode()) + etag = headers['ETag'] = base64.b32encode(etag.digest()).decode() + if if_none_match == etag: + return werkzeug.wrappers.Response(status=304, headers=headers) + + if not pr.batch_id.target: + im = render_inconsistent_batch(pr.batch_id) + else: + im = render_full_table(pr, branches, repos, batches) + + buffer = io.BytesIO() + im.save(buffer, 'png', optimize=True) + return werkzeug.wrappers.Response(buffer.getvalue(), headers=headers) + + +def render_full_table(pr, branches, repos, batches): + with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf', 'rb') as f: + font = ImageFont.truetype(f, size=16, layout_engine=0) + f.seek(0) + supfont = ImageFont.truetype(f, size=13, layout_engine=0) + with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Bold.ttf', 'rb') as f: + bold = ImageFont.truetype(f, size=16, layout_engine=0) + # getbbox returns (left, top, right, bottom) + rows = {b: font.getbbox(b.name)[3] for b in branches} + rows[None] = max(bold.getbbox(r.name)[3] for r in repos) + columns = {r: bold.getbbox(r.name)[2] for r in repos} + columns[None] = max(font.getbbox(b.name)[2] for b in branches) + for r, b in product(repos, branches): + ps = batches[r, b] w = h = 0 for p in ps['prs']: _, _, ww, hh = font.getbbox(f" #{p['number']}") @@ -223,17 +233,12 @@ def raster_render(pr): rows[b] = max(rows.get(b, 0), h) columns[r] = max(columns.get(r, 0), w) - etag = headers['ETag'] = base64.b32encode(etag.digest()).decode() - if if_none_match == etag: - return werkzeug.wrappers.Response(status=304, headers=headers) - pad_w, pad_h = 20, 5 image_height = sum(rows.values()) + 2 * pad_h * len(rows) image_width = sum(columns.values()) + 2 * pad_w * len(columns) im = Image.new("RGB", (image_width+1, image_height+1), color='white') draw = ImageDraw.Draw(im, 'RGB') draw.font = font - # for reasons of that being more convenient we store the bottom of the # current row, so getting the top edge requires subtracting h w = left = bottom = 0 @@ -244,12 +249,12 @@ def raster_render(pr): background = BG['info'] if b == pr.target or r == pr.repository else BG[None] w, h = columns[r] + 2 * pad_w, rows[b] + 2 * pad_h - if r is None: # branch cell in row + if r is None: # branch cell in row left = 0 bottom += h if b: draw.rectangle( - (left + 1, bottom - h + 1, left+w - 1, bottom - 1), + (left + 1, bottom - h + 1, left + w - 1, bottom - 1), background, ) draw.text( @@ -257,14 +262,14 @@ def raster_render(pr): b.name, fill=blend(TEXT, opacity, over=background), ) - elif b is None: # repo cell in top row - draw.rectangle((left + 1, bottom - h + 1, left+w - 1, bottom - 1), background) + elif b is None: # repo cell in top row + draw.rectangle((left + 1, bottom - h + 1, left + w - 1, bottom - 1), background) draw.text((left + pad_w, bottom - h + pad_h), r.name, fill=TEXT, font=bold) # draw the bottom-right edges of the cell draw.line([ - (left, bottom), # bottom-left - (left + w, bottom), # bottom-right - (left+w, bottom-h) # top-right + (left, bottom), # bottom-left + (left + w, bottom), # bottom-right + (left + w, bottom - h) # top-right ], fill=(172, 176, 170)) if r is None or b is None: continue @@ -275,7 +280,7 @@ def raster_render(pr): if pr in ps['pr_ids']: bgcolor = lighten(bgcolor, by=-0.05) background = blend(bgcolor, opacity, over=background) - draw.rectangle((left + 1, bottom - h + 1, left+w - 1, bottom - 1), background) + draw.rectangle((left + 1, bottom - h + 1, left + w - 1, bottom - 1), background) top = bottom - h + pad_h offset = left + pad_w @@ -286,13 +291,13 @@ def raster_render(pr): x, _, ww, hh = font.getbbox(label) if p['closed']: draw.line([ - (offset+x, top + hh - hh/3), - (offset+x+ww, top + hh - hh/3), + (offset + x, top + hh - hh / 3), + (offset + x + ww, top + hh - hh / 3), ], fill=foreground) offset += ww if not p['attached']: # overdraw top border to mark the detachment - draw.line([(left, bottom-h), (left+w, bottom-h)], fill=ERROR) + draw.line([(left, bottom - h), (left + w, bottom - h)], fill=ERROR) for attribute in filter(None, [ 'error' if p['pr'].error else '', '' if p['checked'] else 'missing statuses', @@ -300,17 +305,43 @@ def raster_render(pr): '' if p['attached'] else 'detached', 'staged' if p['pr'].staging_id else 'ready' if p['pr']._ready else '' ]): - label = f' {attribute}' color = SUCCESS if attribute in ('staged', 'ready') else ERROR + label = f' {attribute}' draw.text((offset, top), label, fill=blend(color, opacity, over=background), font=supfont) offset += supfont.getbbox(label)[2] offset += math.ceil(supfont.getlength(" ")) - buffer = io.BytesIO() - im.save(buffer, 'png', optimize=True) - return werkzeug.wrappers.Response(buffer.getvalue(), headers=headers) + return im + + +def render_inconsistent_batch(batch): + """If a batch has inconsistent targets, just point out the inconsistency by + listing the PR and targets + """ + with file_open('web/static/fonts/google/Open_Sans/Open_Sans-Regular.ttf', 'rb') as f: + font = ImageFont.truetype(f, size=16, layout_engine=0) + + im = Image.new("RGB", (4000, 4000), color=BG['danger']) + w = h = 0 + def draw(label, draw=ImageDraw.Draw(im)): + nonlocal w, h + + draw.text((0, h), label, fill=blend(ERROR, 1.0, over=BG['danger']), font=font) + + _, _, ww, hh = font.getbbox(label) + w = max(w, ww) + h += hh + + draw(" Inconsistent targets:") + for p in batch.prs: + draw(f" • {p.display_name} has target '{p.target.name}'") + draw(" To resolve, either retarget or close the mis-targeted pull request(s).") + + return im.crop((0, 0, w+10, h+5)) + + Color = Tuple[int, int, int] TEXT: Color = (102, 102, 102) @@ -322,6 +353,15 @@ BG: Mapping[str | None, Color] = collections.defaultdict(lambda: (255, 255, 255) 'warning': (252, 248, 227), 'danger': (242, 222, 222), }) + + +CHECK_MARK = "\uf00c" +CROSS = "\uf00d" +BOX_EMPTY = "\uf096" +DARK_BOX = "\uf0c8" +DARK_CHECK = "\uf14a" + + def blend_single(c: int, over: int, opacity: float) -> int: return round(over * (1 - opacity) + c * opacity)