From patchwork Fri Aug 14 15:35:58 2026 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Paolo Wattebled X-Patchwork-Id: 95281 Return-Path: X-Spam-Checker-Version: SpamAssassin 3.4.0 (2014-02-07) on aws-us-west-2-korg-lkml-1.web.codeaurora.org Received: from aws-us-west-2-korg-lkml-1.web.codeaurora.org (localhost.localdomain [127.0.0.1]) by smtp.lore.kernel.org (Postfix) with ESMTP id 9F5ECC5CFCF for ; Fri, 14 Aug 2026 15:36:23 +0000 (UTC) Received: from mail.savoirfairelinux.com (mail.savoirfairelinux.com [208.88.110.44]) by mx.groups.io with SMTP id smtpd.msgproc02-g2.15786.1786721773491087293 for ; Fri, 14 Aug 2026 08:36:13 -0700 Authentication-Results: mx.groups.io; dkim=pass header.i=@savoirfairelinux.com header.s=DFC430D2-D198-11EC-948E-34200CB392D2 header.b=EgvLfwKD; spf=pass (domain: savoirfairelinux.com, ip: 208.88.110.44, mailfrom: paolo.wattebled@savoirfairelinux.com) Received: from localhost (localhost [127.0.0.1]) by mail.savoirfairelinux.com (Postfix) with ESMTP id A15563D8189E for ; Fri, 14 Aug 2026 11:36:12 -0400 (EDT) Received: from mail.savoirfairelinux.com ([127.0.0.1]) by localhost (mail.savoirfairelinux.com [127.0.0.1]) (amavis, port 10032) with ESMTP id yCWV2-FWbcsO; Fri, 14 Aug 2026 11:36:08 -0400 (EDT) Received: from localhost (localhost [127.0.0.1]) by mail.savoirfairelinux.com (Postfix) with ESMTP id 3A58D3D818A9; Fri, 14 Aug 2026 11:36:08 -0400 (EDT) DKIM-Filter: OpenDKIM Filter v2.10.3 mail.savoirfairelinux.com 3A58D3D818A9 DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=savoirfairelinux.com; s=DFC430D2-D198-11EC-948E-34200CB392D2; t=1786721768; bh=uMhOOIMdte8VwwUUE2ApI9jIkbzzw5NZjzwMrOoG/gM=; h=From:To:Date:Message-ID:MIME-Version; b=EgvLfwKDuIKMRB1qiO5AVc9ShxZfU0WvOP2dUAdKCufGS6/uqFPXpL/+LJgu0scI3 NVMWz52POXHMcGDsyZNBo+dFaGtiC/c2Nl1gsrGIRA4IFBknZzzNO9MMiEkZFcOuf5 C1LVLRj1wZ4OumUdC8sXFBdFGQW5/mfkNkCLpgXFcnYi8DdHg8GGFBWCu8Estdbmy+ I1fypPQvy9u02Cbory3j7KGpupmyYDPh+1hqekGVXeO/BnC8bSvRNMqxcwk1DFPxww cRSO18w9nHRedMdZvlWP/sP6nCTyNjAF91U7ftPbgqBTu4G0apmw09E7iVWkvsfbOe iHm1x1NThbnhQ== X-Virus-Scanned: amavis at mail.savoirfairelinux.com Received: from mail.savoirfairelinux.com ([127.0.0.1]) by localhost (mail.savoirfairelinux.com [127.0.0.1]) (amavis, port 10026) with ESMTP id n24ilhdkweI9; Fri, 14 Aug 2026 11:36:08 -0400 (EDT) Received: from pwattebled.mtl.sfl (unknown [192.168.51.254]) by mail.savoirfairelinux.com (Postfix) with ESMTPSA id 09CE83D818FA; Fri, 14 Aug 2026 11:36:08 -0400 (EDT) From: Paolo Wattebled To: bitbake-devel@lists.openembedded.org Cc: Paolo Wattebled Subject: [PATCH v2 5/5] toaster: Add recipe variables view on UI Date: Fri, 14 Aug 2026 11:35:58 -0400 Message-ID: <20260814153602.2107764-6-paolo.wattebled@savoirfairelinux.com> X-Mailer: git-send-email 2.55.0 In-Reply-To: <20260814153602.2107764-1-paolo.wattebled@savoirfairelinux.com> References: <20260814153602.2107764-1-paolo.wattebled@savoirfairelinux.com> MIME-Version: 1.0 List-Id: X-Webhook-Received: from 45-33-107-173.ip.linodeusercontent.com [45.33.107.173] by aws-us-west-2-korg-lkml-1.web.codeaurora.org with HTTPS for ; Fri, 14 Aug 2026 15:36:23 -0000 X-Groupsio-URL: https://lists.openembedded.org/g/bitbake-devel/message/19943 Expose build-specific recipe variable snapshots through a searchable and paginated Variables tab. Show snapshot counts in the built recipes table and recipe package navigation. Cover build and recipe scoping, pagination, ordering, escaping, empty states and query parameter validation with view tests. AI-Generated: Uses GitHub Copilot and OpenCode with GPT-5.6 Sol Signed-off-by: Paolo Wattebled --- lib/toaster/tests/views/test_views.py | 155 +++++++++++++++++- lib/toaster/toastergui/buildtables.py | 11 +- lib/toaster/toastergui/templates/recipe.html | 32 ++++ .../toastergui/templates/recipe_packages.html | 7 + lib/toaster/toastergui/views.py | 68 +++++++- 5 files changed, 269 insertions(+), 4 deletions(-) diff --git a/lib/toaster/tests/views/test_views.py b/lib/toaster/tests/views/test_views.py index e1adfcf86..bed572c76 100644 --- a/lib/toaster/tests/views/test_views.py +++ b/lib/toaster/tests/views/test_views.py @@ -10,14 +10,16 @@ """Test cases for Toaster GUI and ReST.""" import os +import zlib import pytest from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse from django.db.models import Q -from orm.models import Project, Package +from orm.models import Build, Project, Package from orm.models import Layer_Version, Recipe +from orm.models import RecipeVariable from orm.models import CustomImageRecipe from orm.models import CustomImagePackage @@ -61,6 +63,14 @@ class ViewTests(TestCase): if BuildEnvironment.objects.count() == 0: BuildEnvironment.objects.create(betype=BuildEnvironment.TYPE_LOCAL) + @staticmethod + def _recipe_variables(build, recipe, values): + return RecipeVariable( + build=build, recipe=recipe, + variable_count=len(values), + variables=zlib.compress(json.dumps( + values, separators=(',', ':')).encode('utf-8'))) + def test_get_base_call_returns_html(self): """Basic test for all-projects view""" @@ -90,6 +100,149 @@ class ViewTests(TestCase): self.assertTrue(name_found, "project name not found in projects table") + def test_recipe_variables_tab_scopes_searches_and_escapes(self): + build = Build.objects.get(pk=1) + other_build = Build.objects.get(pk=2) + other_recipe = Recipe.objects.exclude(pk=self.recipe1.pk).first() + RecipeVariable.objects.bulk_create([ + self._recipe_variables(build, self.recipe1, { + 'SRC_URI': 'git://example.invalid/src', + 'SPECIAL': '', + }), + self._recipe_variables(other_build, self.recipe1, { + 'OTHER_BUILD': 'hidden', + }), + self._recipe_variables(build, other_recipe, { + 'OTHER_RECIPE': 'hidden', + }), + ]) + url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5')) + + response = self.client.get(url, { + 'count': 100, + 'page': 1, + 'orderby': 'variable_name:+', + 'search': 'SRC_URI', + }) + + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'recipe.html') + self.assertContains(response, 'SRC_URI') + self.assertContains(response, 'git://example.invalid/src') + self.assertNotContains(response, 'OTHER_BUILD') + self.assertNotContains(response, 'OTHER_RECIPE') + + response = self.client.get(url, { + 'count': 100, + 'page': 1, + 'orderby': 'variable_name:+', + }) + self.assertContains(response, 'Variables (2)') + self.assertContains(response, '<script>alert(1)</script>') + self.assertNotContains(response, '') + + def test_recipe_variables_tab_redirects_paginates_and_handles_empty(self): + build = Build.objects.get(pk=1) + url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5')) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn('count=100', response.url) + self.assertIn('orderby=variable_name%3A%2B', response.url) + + RecipeVariable.objects.bulk_create([ + self._recipe_variables(build, self.recipe1, { + 'VAR_%02d' % index: str(index) for index in range(12) + }) + ]) + response = self.client.get(url, { + 'count': 10, + 'page': 2, + 'orderby': 'variable_name:+', + }) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['objects'].paginator.count, 12) + self.assertEqual(response.context['objects'].number, 2) + self.assertContains(response, 'VAR_10') + self.assertContains(response, 'VAR_11') + self.assertNotContains(response, 'VAR_00') + + RecipeVariable.objects.filter(build=build, recipe=self.recipe1).delete() + response = self.client.get(url, { + 'count': 100, + 'page': 1, + 'orderby': 'variable_name:+', + }) + self.assertContains(response, 'No resolved recipe datastore variables are available.') + + def test_recipe_variables_tab_bounds_query_parameters(self): + build = Build.objects.get(pk=1) + RecipeVariable.objects.bulk_create([ + self._recipe_variables(build, self.recipe1, {'SRC_URI': 'value'}) + ]) + url = reverse('recipe', args=(build.pk, self.recipe1.pk, '5')) + + response = self.client.get(url, { + 'count': 'alert(document.domain)', + 'page': 1, + 'orderby': 'variable_value:+', + 'filter': 'variable_value__regex:(a+)+$', + }) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context['objects'].paginator.per_page, 100) + self.assertNotContains(response, 'alert(document.domain)') + self.assertEqual( + [variable['variable_name'] + for variable in response.context['objects'].object_list], + ['SRC_URI']) + + response = self.client.get(url, { + 'count': 100, + 'page': 1, + 'orderby': 'variable_name:+', + 'search': ('SRC_URI ' * 20) + ('x' * 300), + }) + self.assertLessEqual(len(response.context['search_term']), 256) + self.assertLessEqual(len(response.context['search_term'].split()), 16) + + def test_built_recipes_table_shows_variable_count(self): + build = Build.objects.get(pk=1) + self._recipe_variables(build, self.recipe1, { + 'FOO': 'one', + 'BAR': 'two', + }).save() + url = reverse('recipes', args=(build.pk,)) + + response = self.client.get(url, { + 'format': 'json', + 'limit': 25, + 'page': 1, + }) + data = json.loads(response.content) + row = next(row for row in data['rows'] + if self.recipe1.name in row['name']) + + self.assertIn('Variables', [column['title'] for column in data['columns']]) + self.assertIn('>2', row['variable_count']) + + def test_recipe_packages_tab_shows_variable_count(self): + build = Build.objects.get(pk=1) + self._recipe_variables(build, self.recipe1, { + 'FOO': 'one', + 'BAR': 'two', + }).save() + + response = self.client.get(reverse( + 'recipe_packages', args=(build.pk, self.recipe1.pk)), { + 'count': 10, + 'page': 1, + 'orderby': 'name:+', + }) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Variables (2)') + def test_typeaheads(self): """Test typeahead ReST API""" layers_url = reverse('xhr_layerstypeahead', args=(self.project.id,)) diff --git a/lib/toaster/toastergui/buildtables.py b/lib/toaster/toastergui/buildtables.py index 327059d00..52c3dd779 100644 --- a/lib/toaster/toastergui/buildtables.py +++ b/lib/toaster/toastergui/buildtables.py @@ -239,7 +239,10 @@ class BuiltRecipesTable(BuildTablesMixin): def setup_queryset(self, *args, **kwargs): build = Build.objects.get(pk=kwargs['build_id']) self.static_context_extra['build'] = build - self.queryset = build.get_recipes() + self.queryset = build.get_recipes().annotate( + variable_count=Sum( + 'recipevariable__variable_count', + filter=Q(recipevariable__build=build), default=0)) self.queryset = self.queryset.order_by(self.default_orderby) def setup_columns(self, *args, **kwargs): @@ -323,6 +326,12 @@ class BuiltRecipesTable(BuildTablesMixin): hideable=False, field_name="version") + self.add_column( + title="Variables", + field_name="variable_count", + static_data_name="variable_count", + static_data_template='{{data.variable_count|default:0}}') + self.add_column(title="Dependencies", static_data_name="dependencies", static_data_template=depends_on_tmpl) diff --git a/lib/toaster/toastergui/templates/recipe.html b/lib/toaster/toastergui/templates/recipe.html index 4b5301b54..be99ebc54 100644 --- a/lib/toaster/toastergui/templates/recipe.html +++ b/lib/toaster/toastergui/templates/recipe.html @@ -52,6 +52,13 @@ Reverse build dependencies ({{object.r_dependencies_depends.all.count}}) +
  • + + + Variables ({{recipe_variable_count}}) + +
  • @@ -277,6 +284,31 @@ {% endif %}
    +
    + {% if not objects and not request.GET.search %} +
    + No resolved recipe datastore variables are available. +
    + {% else %} + {% with "variables" as search_what %} + {% include "detail_search_header.html" %} + {% endwith %} + {% if objects %} + + {% include "detail_sorted_header.html" %} + + {% for variable in objects %} + + + + + {% endfor %} + +
    {{variable.variable_name}}{{variable.variable_value}}
    + {% include "detail_pagination_bottom.html" %} + {% endif %} + {% endif %} +
    diff --git a/lib/toaster/toastergui/templates/recipe_packages.html b/lib/toaster/toastergui/templates/recipe_packages.html index 37a586f38..abd64ca7e 100644 --- a/lib/toaster/toastergui/templates/recipe_packages.html +++ b/lib/toaster/toastergui/templates/recipe_packages.html @@ -51,6 +51,13 @@ Reverse build dependencies ({{recipe.r_dependencies_depends.all.count}}) +
  • + + + Variables ({{variable_count}}) + +
  • {#
    #} diff --git a/lib/toaster/toastergui/views.py b/lib/toaster/toastergui/views.py index 061e6436c..26cf5ca26 100644 --- a/lib/toaster/toastergui/views.py +++ b/lib/toaster/toastergui/views.py @@ -10,6 +10,7 @@ import ast import re import subprocess import sys +import zlib import bb.cooker from bb.ui import toasterui @@ -20,6 +21,7 @@ from django.db import IntegrityError from django.shortcuts import render, redirect, get_object_or_404, HttpResponseRedirect from django.utils.http import urlencode from orm.models import Build, Target, Task, Layer, Layer_Version, Recipe +from orm.models import RecipeVariable from orm.models import LogMessage, Variable, Package_Dependency, Package from orm.models import Task_Dependency, Package_File from orm.models import Target_Installed_Package, Target_File @@ -619,10 +621,13 @@ def recipe(request, build_id, recipe_id, active_tab="1"): layer = Layer.objects.get(pk=layer_version.layer_id) tasks_list = Task.objects.filter(recipe_id = recipe_id, build_id = build_id).exclude(order__isnull=True).exclude(task_name__endswith='_setscene').exclude(outcome=Task.OUTCOME_NA) package_count = Package.objects.filter(recipe_id = recipe_id).filter(build_id = build_id).filter(size__gte=0).count() + recipe_variable_count = RecipeVariable.objects.filter( + build_id=build_id, recipe_id=recipe_id).values_list( + 'variable_count', flat=True).first() or 0 - if active_tab != '1' and active_tab != '3' and active_tab != '4' : + if active_tab not in ('1', '3', '4', '5'): active_tab = '1' - tab_states = {'1': '', '3': '', '4': ''} + tab_states = {'1': '', '3': '', '4': '', '5': ''} tab_states[active_tab] = 'active' context = { @@ -632,8 +637,63 @@ def recipe(request, build_id, recipe_id, active_tab="1"): 'layer' : layer, 'tasks' : tasks_list, 'package_count' : package_count, + 'recipe_variable_count' : recipe_variable_count, 'tab_states' : tab_states, } + + if active_tab == '5': + (requested_pagesize, requested_orderby) = _get_parameters_values( + request, 100, 'variable_name:+') + pagesize = requested_pagesize \ + if str(requested_pagesize) in ('10', '25', '50', '100', '150') \ + else 100 + orderby = requested_orderby \ + if requested_orderby in ('variable_name:+', 'variable_name:-') \ + else 'variable_name:+' + mandatory_parameters = { + 'count': pagesize, + 'page': 1, + 'orderby': orderby, + } + if _verify_parameters(request.GET, mandatory_parameters): + return _redirect_parameters( + 'recipe', request.GET, mandatory_parameters, + build_id=build_id, recipe_id=recipe_id, active_tab='5') + + search_term = request.GET.get('search', '')[:256] + search_term = ' '.join(search_term.split()[:16]) + snapshot = RecipeVariable.objects.filter( + build_id=build_id, recipe_id=recipe_id).first() + variables = [] + if snapshot: + values = json.loads(zlib.decompress(snapshot.variables).decode('utf-8')) + variables = [ + {'variable_name': name, 'variable_value': value} + for name, value in values.items() + if not search_term or search_term.lower() in name.lower() + or search_term.lower() in value.lower() + ] + variables.sort( + key=lambda variable: variable['variable_name'], + reverse=orderby.endswith(':-')) + context['variable_count'] = len(variables) + context['objects'] = _build_page_range( + Paginator(variables, pagesize), request.GET.get('page', 1)) + context['object_count'] = context['variable_count'] + context['validated_pagesize'] = pagesize + context['search_term'] = search_term + context['search_orderby'] = 'variable_name:+' + context['tablecols'] = [ + { + 'name': 'Variable', + 'orderfield': _get_toggle_order(request, 'variable_name'), + 'ordericon': _get_toggle_order_icon(request, 'variable_name'), + 'orderkey': 'variable_name', + }, + {'name': 'Value'}, + ] + _set_parameters_values(pagesize, orderby, request) + return toaster_render(request, template, context) def recipe_packages(request, build_id, recipe_id): @@ -651,6 +711,9 @@ def recipe_packages(request, build_id, recipe_id): recipe_object = Recipe.objects.get(pk=recipe_id) queryset = Package.objects.filter(recipe_id = recipe_id).filter(build_id = build_id).filter(size__gte=0) package_count = queryset.count() + variable_count = RecipeVariable.objects.filter( + build_id=build_id, recipe_id=recipe_id).values_list( + 'variable_count', flat=True).first() or 0 queryset = _get_queryset(Package, queryset, filter_string, search_term, ordering_string, 'name') packages = _build_page_range(Paginator(queryset, pagesize),request.GET.get('page', 1)) @@ -660,6 +723,7 @@ def recipe_packages(request, build_id, recipe_id): 'recipe' : recipe_object, 'objects' : packages, 'object_count' : package_count, + 'variable_count' : variable_count, 'tablecols':[ { 'name':'Package',