From 6a89639db2ad4f92982fd770ef2b122de88a73ed Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Tue, 28 Apr 2026 15:11:08 +0200 Subject: [PATCH 1/6] refactor(assignments): remove unused assignment model --- .../ansible_content_assignment_collection_role.rb | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 app/models/foreman_ansible_director/ansible_content_assignment_collection_role.rb diff --git a/app/models/foreman_ansible_director/ansible_content_assignment_collection_role.rb b/app/models/foreman_ansible_director/ansible_content_assignment_collection_role.rb deleted file mode 100644 index d506b4f5..00000000 --- a/app/models/foreman_ansible_director/ansible_content_assignment_collection_role.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -module ForemanAnsibleDirector - class AnsibleContentAssignmentCollectionRole < ::ForemanAnsibleDirector::AnsibleDirectorModel - belongs_to :ansible_content_assignment - belongs_to :ansible_collection_role - end - # TODO: Delete class - assignments are handled via generic assignment model -end From 01af59f3bae400fbd0a2581cf1b7f968c00ce2b2 Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Thu, 30 Apr 2026 12:26:41 +0200 Subject: [PATCH 2/6] wip: feat!(assignments): use fuzzy content assignments Before this commit, Ansible content assignments used a polymorphic association between an Ansible content unit assignable (AnsibleCollectionRole, AnsibleRole) and a consumable (Host, Hostgroup). This was very rigid and incompatible with the dynamic nature of LifecycleEnvironments. When content in a LifecycleEnvironment changed, consumers of said environment were not updated and tried to use the content that was originally assigned. In this commit, the assignment logic is redesigned to use fuzzy matching and dynamic resolution of concrete Ansible content for consumers. Content can now be assigned to ContentResolutionNodes, which represent objects in the "classic" inheritance chain used elsewhere. Current ContentResolutionNodes are: "Hostgroup" -> "Host". AnsibleContentAssignment objects have been adapted to store the following data: - polymorphic consumable, representing the consumer - assignable_namespace - assignable_name - assnignable_role_name - assignable_type - subtractive This new AnsibleContentAssignment implementation, as opposed to the previous one, which used IDs, uses the key fact that these value only lack a version value to form a composite primary key, which can uniquely identifiy an assignable. This fact is leveraged by the two-step resolution algorithm: Step 1: Resolve set of AnsibleContentAssignments for a ContentResolutionNode. In this step, the hierarchical structure of the content inheritance chain is traversed from top (lowest rank) to bottom (ContentResolutionNode we are trying to resolve content for). In this step, a set of unique AnsibleContentAssignments is built, taking the "subtractive" attribute into account. Step 2: Map previously resolved set of AnsibleContentAssignments to actual AnsibleContentUnit (and children) objects. The first step in this step resolves the actual content source used by the ContentResolutionNode, as this too can be inherited from a parent. Here, the algorithm traverses the chain from bottom (highest priority) to top (lowest priority), and uses the first (transitively highest priority) content source it finds. It is an integral condition of a ContentSource, that it may only supply Ansible content in a single version. Mapping the assignable_* attributes to the AnsibleContentUnit object in the set of content provided by ContentSource, gives the version, which, in combination with the other attributes, forms the composite primary key, giving a single, non-ambiguous ContentUnitVersion. ContentResolutionNode and ContentSource are abstract classes, which may be implemented by any arbitrary object. By implementing cr_immediate_predecessor, the resolution hierarchy is implemented by the nodes themselves and can be made dynamic with ease. See for example Hostgroups, which can inherit attributes in themselves. --- .../api/v2/assignments_controller.rb | 83 ++------- .../abstract/content_resolution_node.rb | 23 +++ .../abstract/content_source.rb | 15 ++ .../ansible_content_assignment.rb | 10 - .../concerns/host_extensions.rb | 18 ++ .../concerns/hostgroup_extensions.rb | 18 ++ .../lifecycle_environment.rb | 10 + .../assignment_service.rb | 173 +++++++++++++++--- .../api/v2/assignments/assign.json.rabl | 3 + .../api/v2/assignments/assignments.json.rabl | 83 ++++++--- config/routes.rb | 3 +- .../20260316000001_ansible_director_schema.rb | 14 +- .../AnsibleVariablesViewer.tsx | 0 13 files changed, 316 insertions(+), 137 deletions(-) create mode 100644 app/lib/foreman_ansible_director/abstract/content_resolution_node.rb create mode 100644 app/lib/foreman_ansible_director/abstract/content_source.rb create mode 100644 app/views/foreman_ansible_director/api/v2/assignments/assign.json.rabl delete mode 100644 webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesViewer.tsx diff --git a/app/controllers/foreman_ansible_director/api/v2/assignments_controller.rb b/app/controllers/foreman_ansible_director/api/v2/assignments_controller.rb index b7290bae..0d5a4868 100644 --- a/app/controllers/foreman_ansible_director/api/v2/assignments_controller.rb +++ b/app/controllers/foreman_ansible_director/api/v2/assignments_controller.rb @@ -34,73 +34,9 @@ def assignments target_id: params[:target_id] ) # TODO: Null check target - @assignments = target.resolved_ansible_content - end - - # region ApiDoc: POST /api/v2/ansible_director/assignments - api :POST, '/v2/ansible_director/assignments', N_('Assign Ansible content to a target') - # TRANSLATORS: ApiDoc, do not translate! - description <<~DESC - Assign Ansible content from a source (e.g., a lifecycle environment) to a target. - DESC - param :assignment, Hash, desc: N_('Assignment definition'), required: true do - param :source, Hash, desc: N_('Source (provider of content)'), required: true do - param :type, - %w[ACR], - desc: N_('Type of source. Currently, only Ansible collection roles (ACR) are supported as sources.'), - example: 'ACR', - required: true - param :id, - :number, - desc: N_('ID of the source entity.'), - example: 5, - required: true - end - param :target, Hash, desc: N_('Target (receiver of content)'), required: true do - param :type, - %w[HOST HOSTGROUP], - desc: N_('Type of target. Both hosts (HOST) and hostgroups (HOSTGROUP) are supported as targets.'), - example: 'HOST', - required: true - param :id, - :number, - desc: N_('ID of the target entity.'), - example: 6, - required: true - end - end - # TRANSLATORS: ApiDoc, do not translate! - example <<~EXAMPLE - { - "assignment": { - "source": { - "type": "ACR", - "id": 109 - }, - "target": { - "type": "HOST", - "id": 1 - } - } - } - EXAMPLE - # endregion - def assign - assignment = assignment_params - - source = ::ForemanAnsibleDirector::AssignmentService.find_target( - target_type: assignment[:source][:type], - target_id: assignment[:source][:id] - ) - - target = ::ForemanAnsibleDirector::AssignmentService.find_target( - target_type: assignment[:target][:type], - target_id: assignment[:target][:id] - ) - - ::ForemanAnsibleDirector::AssignmentService.create_assignment( - consumable: source, - assignable: target + @assignments, @resolved_assignments, @hierarchy = ::ForemanAnsibleDirector::AssignmentService.assignments_for( + target: target, + resolve: ::Foreman::Cast.to_bool(params[:resolve]) ) end @@ -155,9 +91,15 @@ def assign } EXAMPLE # endregion - def assign_bulk + def assign + target = ::ForemanAnsibleDirector::AssignmentService.find_target( + target_type: params[:target], + target_id: params[:target_id] + ) + assignments = bulk_assignment_params ::ForemanAnsibleDirector::AssignmentService.create_bulk_assignments( + target: target, assignments: assignments ) end @@ -194,10 +136,7 @@ def bulk_assignment_params return [] if params[:assignments].empty? params.require(:assignments).map do |assignment| - assignment.permit( - source: %i[type id], - target: %i[type id] - ) + assignment.permit(:assignable_type, :assignable_namespace, :assignable_name, :assignable_role_name) end end end diff --git a/app/lib/foreman_ansible_director/abstract/content_resolution_node.rb b/app/lib/foreman_ansible_director/abstract/content_resolution_node.rb new file mode 100644 index 00000000..592bfd17 --- /dev/null +++ b/app/lib/foreman_ansible_director/abstract/content_resolution_node.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Abstract + module ContentResolutionNode + def cr_immediate_predecessor + raise NotImplementedError + end + + def cr_content_assignments + raise NotImplementedError + end + + def cr_name + raise NotImplementedError + end + + def cr_content_source + raise NotImplementedError + end + end + end +end diff --git a/app/lib/foreman_ansible_director/abstract/content_source.rb b/app/lib/foreman_ansible_director/abstract/content_source.rb new file mode 100644 index 00000000..116f8506 --- /dev/null +++ b/app/lib/foreman_ansible_director/abstract/content_source.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Abstract + module ContentSource + def cs_content_unit_versions + raise NotImplementedError + end + + def cs_name + raise NotImplementedError + end + end + end +end diff --git a/app/models/foreman_ansible_director/ansible_content_assignment.rb b/app/models/foreman_ansible_director/ansible_content_assignment.rb index 12f10f46..25b6efb2 100644 --- a/app/models/foreman_ansible_director/ansible_content_assignment.rb +++ b/app/models/foreman_ansible_director/ansible_content_assignment.rb @@ -3,15 +3,5 @@ module ForemanAnsibleDirector class AnsibleContentAssignment < ::ForemanAnsibleDirector::AnsibleDirectorModel belongs_to :consumable, polymorphic: true - belongs_to :assignable, polymorphic: true - - def content_unit_version - case consumable - when ::ForemanAnsibleDirector::AnsibleCollectionRole - consumable.ansible_collection_version # TODO: IMPORTANT! ACV should be referenced as content unit version! - when ::ForemanAnsibleDirector::AnsibleRole - consumable.content_unit_version - end - end end end diff --git a/app/models/foreman_ansible_director/concerns/host_extensions.rb b/app/models/foreman_ansible_director/concerns/host_extensions.rb index 39e0ffed..85a2b3df 100644 --- a/app/models/foreman_ansible_director/concerns/host_extensions.rb +++ b/app/models/foreman_ansible_director/concerns/host_extensions.rb @@ -4,6 +4,8 @@ module ForemanAnsibleDirector module Concerns module HostExtensions extend ActiveSupport::Concern + include ::ForemanAnsibleDirector::Abstract::ContentResolutionNode + included do include ::ForemanAnsibleDirector::Concerns::ContentConsumer @@ -31,6 +33,22 @@ def resolved_ansible_content end content.concat additions end + + def cr_immediate_predecessor + hostgroup + end + + def cr_name + name + end + + def cr_content_assignments + ::ForemanAnsibleDirector::AnsibleContentAssignment.where(consumable: self) + end + + def cr_content_source + ansible_lifecycle_environment + end end end end diff --git a/app/models/foreman_ansible_director/concerns/hostgroup_extensions.rb b/app/models/foreman_ansible_director/concerns/hostgroup_extensions.rb index 44d6332a..ed2d0031 100644 --- a/app/models/foreman_ansible_director/concerns/hostgroup_extensions.rb +++ b/app/models/foreman_ansible_director/concerns/hostgroup_extensions.rb @@ -4,6 +4,7 @@ module ForemanAnsibleDirector module Concerns module HostgroupExtensions extend ActiveSupport::Concern + include ::ForemanAnsibleDirector::Abstract::ContentResolutionNode included do include ::ForemanAnsibleDirector::Concerns::ContentConsumer @@ -14,6 +15,23 @@ module HostgroupExtensions inverse_of: :hostgroups, optional: true end + + def cr_immediate_predecessor + # The nil is redundant and just for clarification + parent || nil + end + + def cr_name + name + end + + def cr_content_assignments + ::ForemanAnsibleDirector::AnsibleContentAssignment.where(consumable: self) + end + + def cr_content_source + ansible_lifecycle_environment + end end end end diff --git a/app/models/foreman_ansible_director/lifecycle_environment.rb b/app/models/foreman_ansible_director/lifecycle_environment.rb index 32981caa..8e731352 100644 --- a/app/models/foreman_ansible_director/lifecycle_environment.rb +++ b/app/models/foreman_ansible_director/lifecycle_environment.rb @@ -2,6 +2,8 @@ module ForemanAnsibleDirector class LifecycleEnvironment < ::ForemanAnsibleDirector::AnsibleDirectorModel + include ::ForemanAnsibleDirector::Abstract::ContentSource + belongs_to :organization, inverse_of: :lifecycle_environments belongs_to :execution_environment, optional: true @@ -46,6 +48,14 @@ def content_unit_versions end end + def cs_content_unit_versions + content_unit_versions + end + + def cs_name + name + end + def assign_execution_environment!(execution_environment_id) execution_env = ::ForemanAnsibleDirector::ExecutionEnvironment.find_by(id: execution_environment_id) diff --git a/app/services/foreman_ansible_director/assignment_service.rb b/app/services/foreman_ansible_director/assignment_service.rb index 71eefe24..970686b6 100644 --- a/app/services/foreman_ansible_director/assignment_service.rb +++ b/app/services/foreman_ansible_director/assignment_service.rb @@ -9,30 +9,25 @@ def destroy_assignment(assignment) end end - def create_assignment(consumable:, - assignable:) + def create_assignment(target:, + assignment:) ActiveRecord::Base.transaction do ::ForemanAnsibleDirector::AnsibleContentAssignment.create!( - consumable: consumable, - assignable: assignable + consumable: target, + assignable_type: assignment[:assignable_type], + assignable_namespace: assignment[:assignable_namespace], + assignable_name: assignment[:assignable_name], + assignable_role_name: if assignment[:assignable_type] == 'ForemanAnsibleDirector::AnsibleCollectionRole' + assignment[:assignable_role_name] + end ) end end - def create_bulk_assignments(assignments:) - cleared_targets = [] + def create_bulk_assignments(target:, assignments:) ActiveRecord::Base.transaction do assignments.each do |assignment| - source_finder = ::ForemanAnsibleDirector::AssignmentService.finder(type: assignment[:source][:type]) - target_finder = ::ForemanAnsibleDirector::AssignmentService.finder(type: assignment[:target][:type]) - source = source_finder.find_by(id: assignment[:source][:id]) - target = target_finder.find_by(id: assignment[:target][:id]) - - unless target.id.in?(cleared_targets) - ::ForemanAnsibleDirector::AnsibleContentAssignment.where(assignable: target).destroy_all - cleared_targets.push(target.id) - end - ::ForemanAnsibleDirector::AnsibleContentAssignment.create!(consumable: source, assignable: target) + create_assignment(target: target, assignment: assignment) end end end @@ -40,13 +35,9 @@ def create_bulk_assignments(assignments:) def finder(type:) case type - when 'ACR' - ::ForemanAnsibleDirector::AnsibleCollectionRole - when 'CONTENT' - ::ForemanAnsibleDirector::ContentUnitVersion - when 'HOST' - Host::Managed - when 'HOSTGROUP' + when 'host' + Host + when 'hostgroup' Hostgroup else # TODO: Actual error message @@ -59,6 +50,142 @@ def find_target(target_type:, target_id:) finder = finder(type: target_type) finder.find_by(id: target_id) end + + def assignments_for(target:, resolve: false) + content_source = resolve_content_source(target) + resolved_assignments, hierarchy = recurse_content_assignments(target) + + return resolved_assignments, nil unless resolve + + resolved = resolve_content_units(content_source, resolved_assignments) + + [resolved_assignments, resolved, hierarchy] + end + + private + + def recurse_content_assignments(target, hierarchy = []) + node_predecessor = target.cr_immediate_predecessor + + hierarchy << target + + if node_predecessor.nil? + node_assignments = target.cr_content_assignments + return node_assignments, hierarchy + end + + upper_assignments, hierarchy = recurse_content_assignments(node_predecessor, hierarchy) + [merge_assignments(upper_assignments, target.cr_content_assignments), hierarchy] + end + + def merge_assignments(preceeding_assignments, assignments) + merged_hash = {} + + preceeding_assignments.each do |assignment| + assignment_key = [ + assignment.assignable_namespace, + assignment.assignable_name, + assignment.assignable_role_name, + assignment.assignable_type, + ] + merged_hash[assignment_key] = assignment + end + + assignments.each do |assignment| + assignment_key = [ + assignment.assignable_namespace, + assignment.assignable_name, + assignment.assignable_role_name, + assignment.assignable_type, + ] + + if assignment.subtractive + merged_hash.delete(assignment_key) + else + merged_hash[assignment_key] = assignment + end + end + + merged_hash.values + end + + def resolve_content_units(content_source, assignments) + cs_content_unit_versions = content_source.cs_content_unit_versions + + role_lookup = {} + collection_role_lookup = {} + + cs_content_unit_versions.each do |cuv| + versionable = cuv.versionable + + case versionable.class.name + when 'ForemanAnsibleDirector::AnsibleRole' + key = [versionable.namespace, versionable.name] + role_lookup[key] = cuv + + when 'ForemanAnsibleDirector::AnsibleCollection' + + cuv.ansible_collection_roles.each do |collection_role| + role_key = [ + versionable.namespace, + versionable.name, + collection_role.name, + ] + collection_role_lookup[role_key] = collection_role + end + end + end + + matched = [] + + assignments.each do |assignment| + namespace = assignment.assignable_namespace + name = assignment.assignable_name + role_name = assignment.assignable_role_name + type = assignment.assignable_type + + case type + when 'ForemanAnsibleDirector::AnsibleCollectionRole' + + acr_key = [namespace, name, role_name] + collection_role = collection_role_lookup[acr_key] + + if collection_role + + matched << { + type: type, + assignment: assignment, + cuv: collection_role, + } + else + # TODO: Log warning + end + + when 'ForemanAnsibleDirector::AnsibleRole' + base_key = [namespace, name] + if role_lookup.key?(base_key) + cu_version = role_lookup[base_key] + matched << { + type: type, + assignment: assignment, + cuv: cu_version, + } + else + # TODO: Log warning + end + end + end + + matched + end + + def resolve_content_source(target) + raise if target.nil? # Do something, as no CS is found in the inheritance chain + + return target.cr_content_source unless target.cr_content_source.nil? + + resolve_content_source target.cr_immediate_predecessor + end end end end diff --git a/app/views/foreman_ansible_director/api/v2/assignments/assign.json.rabl b/app/views/foreman_ansible_director/api/v2/assignments/assign.json.rabl new file mode 100644 index 00000000..05e51edb --- /dev/null +++ b/app/views/foreman_ansible_director/api/v2/assignments/assign.json.rabl @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +extends 'api/v2/common/response', object: @ctx diff --git a/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl b/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl index 71d6ef98..83122b27 100644 --- a/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl +++ b/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl @@ -1,29 +1,60 @@ # frozen_string_literal: true -collection @assignments - -attributes :id - -node :consumable_id do |assignment| - assignment.consumable.id -end - -node :consumable_name do |assignment| - assignment.consumable.name -end - -node :source_type do |assignment| - assignment.content_unit_version.content_unit_type -end - -node :source_identifier do |assignment| - assignment.content_unit_version.versionable.full_name -end - -node :source_id do |assignment| - assignment.content_unit_version.versionable.id -end - -node :source_version do |assignment| - assignment.content_unit_version.version +object @assignments + +node(:results) do + { + assignments: @assignments.map do |assignment| + { + id: assignment.id, + assignable_namespace: assignment.assignable_namespace, + assignable_name: assignment.assignable_name, + **( + if assignment.assignable_type == 'ForemanAnsibleDirector::AnsibleCollectionRole' + { assignable_role_name: assignment.assignable_role_name } + else + {} + end + ), + assignable_type: assignment.assignable_type, + consumable_id: assignment.consumable_id, + consumable_type: if assignment.consumable_type == 'Host::Managed' || assignment.consumable_type == 'Host::Base' + 'Host' + else + assignment.consumable_type + end, + consumable_name: assignment.consumable.name, + subtractive: assignment.subtractive, + resolved: + if @resolved_assignments + resolved = @resolved_assignments.find do |r| + r[:assignment] == assignment + end + + unless resolved.nil? + if resolved[:type] == 'ForemanAnsibleDirector::AnsibleCollectionRole' + { + version: resolved[:cuv].ansible_collection_version.version, + } + else + { + version: resolved[:cuv].version, + } + end + end + end, + } + end, + hierarchy: @hierarchy.reverse!.map do |node| + { + id: node.id, + name: node.cr_name, + type: if node.instance_of?(Host::Managed) || node.instance_of?(Host::Base) + 'Host' + else + node.class.name + end, + } + end, + } end diff --git a/config/routes.rb b/config/routes.rb index 17f66654..933ee6cc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -65,8 +65,7 @@ resources :assignments, only: [] do collection do get '/:target/:target_id', action: :assignments - post '/', action: :assign - post '/bulk', action: :assign_bulk + post '/:target/:target_id', action: :assign end member do delete '/', action: :destroy diff --git a/db/migrate/20260316000001_ansible_director_schema.rb b/db/migrate/20260316000001_ansible_director_schema.rb index 3591e21f..88b0b17f 100644 --- a/db/migrate/20260316000001_ansible_director_schema.rb +++ b/db/migrate/20260316000001_ansible_director_schema.rb @@ -109,9 +109,13 @@ def change t.timestamps end + # ====== Assignments ====== create_table :ad_ansible_content_assignments do |t| - t.references :assignable, polymorphic: true, null: false t.references :consumable, polymorphic: true, null: false + t.string :assignable_name, null: false + t.string :assignable_namespace, null: false + t.string :assignable_role_name, null: true + t.string :assignable_type, null: false t.boolean :subtractive, default: false t.timestamps end @@ -266,9 +270,11 @@ def change # ======= Assignments ======= add_index :ad_ansible_content_assignments, - %i[assignable_type assignable_id consumable_type consumable_id], - unique: true, - name: 'idx_ad_aca_unique' + %i[consumable_id consumable_type], + name: 'idx_ad_aca_consumable' + add_index :ad_ansible_content_assignments, + %i[assignable_name assignable_namespace assignable_role_name], + name: 'idx_ad_aca_assignable' # ====== Extensions ====== add_reference :hosts, :ansible_lifecycle_environment, foreign_key: { to_table: :ad_lifecycle_environments } diff --git a/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesViewer.tsx b/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesViewer.tsx deleted file mode 100644 index e69de29b..00000000 From 344a86306014265d22953cb1ba134897be6a5912 Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Mon, 4 May 2026 16:32:17 +0200 Subject: [PATCH 3/6] wip: feat(issues): add structured error handling Before this commit, select actions raised exceptions on failure, which was caught by the global exception handler. While this was sufficient for some of the actions, certain others, such as content resolution, can (and sometimes do) fail with warnings or multiple errors. To address the need for multiple errors/warnings, a per-request context, RequestContext, is added here. This context uses the fact that the underlying server, Puma, spawns a new thread per request. As such, the context can be considered thread-global. This context is available to the entire code downstream of the called action, as long as it is being executed on the same thread. This is the case for the majority of the code and as such, the context only requires special attention for DynFlow actions, which was not done in this commit. This context allows developers to add warnings and errors to the current request in a similar fashion to ActiveModel::Errors. Downstream of the business logic, the RequestContext is further used to construct the response for the current request. To unify response shapes across all actions of this plugin, the following shape has been defined: { "status": "", "errors": [], "warnings": [], "logs": [], "changed": [], "created": [], "deleted": [], "results": {} } The "status" value is determined automatically, based on the logged errors/warnings and is either "success", "error", or "warning". This provides a consistent interface for the API, which can be used by the UI to display the result of an action in great detail. TODO: - use response.json.rabl across all actions - gate changed, logs, created, deleted beind loglevel --- .../api/v2/ansible_director_api_controller.rb | 13 +++ .../issues/base_issue.rb | 29 +++++ .../issues/errors/base_error.rb | 9 ++ .../issues/warnings/base_warning.rb | 9 ++ ...esolution_candidate_for_collection_role.rb | 42 ++++++++ .../no_resolution_candidate_for_role.rb | 38 +++++++ .../ansible_director_service.rb | 9 ++ .../assignment_service.rb | 17 ++- .../request_ctx/request_context.rb | 101 ++++++++++++++++++ .../request_ctx/request_context_helper.rb | 11 ++ .../api/v2/assignments/assignments.json.rabl | 2 +- .../api/v2/common/response.json.rabl | 25 +++++ 12 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 app/lib/foreman_ansible_director/issues/base_issue.rb create mode 100644 app/lib/foreman_ansible_director/issues/errors/base_error.rb create mode 100644 app/lib/foreman_ansible_director/issues/warnings/base_warning.rb create mode 100644 app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_collection_role.rb create mode 100644 app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_role.rb create mode 100644 app/services/foreman_ansible_director/ansible_director_service.rb create mode 100644 app/services/foreman_ansible_director/request_ctx/request_context.rb create mode 100644 app/services/foreman_ansible_director/request_ctx/request_context_helper.rb create mode 100644 app/views/foreman_ansible_director/api/v2/common/response.json.rabl diff --git a/app/controllers/foreman_ansible_director/api/v2/ansible_director_api_controller.rb b/app/controllers/foreman_ansible_director/api/v2/ansible_director_api_controller.rb index b4491029..ec21d65f 100644 --- a/app/controllers/foreman_ansible_director/api/v2/ansible_director_api_controller.rb +++ b/app/controllers/foreman_ansible_director/api/v2/ansible_director_api_controller.rb @@ -7,6 +7,19 @@ class AnsibleDirectorApiController < ::Api::V2::BaseController include ::Api::Version2 include ::Foreman::Controller::AutoCompleteSearch + include ::ForemanAnsibleDirector::RequestCtx::RequestContextHelper + + around_action :attach_request_ctx + + def attach_request_ctx + ::ForemanAnsibleDirector::RequestCtx::RequestContext.with_context( + ::ForemanAnsibleDirector::RequestCtx::RequestContext.new(request.request_id) + ) do + @ctx = ctx + yield + end + end + def find_organization @organization = Organization.current || find_optional_organization if @organization.nil? diff --git a/app/lib/foreman_ansible_director/issues/base_issue.rb b/app/lib/foreman_ansible_director/issues/base_issue.rb new file mode 100644 index 00000000..8e5c69d5 --- /dev/null +++ b/app/lib/foreman_ansible_director/issues/base_issue.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Issues + class BaseIssue + def initialize(**kwargs) + end + + def title + raise NotImplementedError + end + + def message + raise NotImplementedError + end + + def render_for_response + { + title: title, + message: message, + } + end + + def render_for_logs + render_for_response + end + end + end +end diff --git a/app/lib/foreman_ansible_director/issues/errors/base_error.rb b/app/lib/foreman_ansible_director/issues/errors/base_error.rb new file mode 100644 index 00000000..bd18772b --- /dev/null +++ b/app/lib/foreman_ansible_director/issues/errors/base_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Issues + module Errors + class BaseError < BaseIssue; end + end + end +end diff --git a/app/lib/foreman_ansible_director/issues/warnings/base_warning.rb b/app/lib/foreman_ansible_director/issues/warnings/base_warning.rb new file mode 100644 index 00000000..243c9d39 --- /dev/null +++ b/app/lib/foreman_ansible_director/issues/warnings/base_warning.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Issues + module Warnings + class BaseWarning < BaseIssue; end + end + end +end diff --git a/app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_collection_role.rb b/app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_collection_role.rb new file mode 100644 index 00000000..44594053 --- /dev/null +++ b/app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_collection_role.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Issues + module Warnings + class NoResolutionCandidateForCollectionRole < BaseWarning + def initialize(collection_namespace:, + collection_name:, + collection_role_identifier:, + content_source:, + assignment_id:) + @collection_namespace = collection_namespace + @collection_name = collection_name + @collection_role_identifier = collection_role_identifier + @content_source = content_source + @assignment_id = assignment_id + super + end + + def title + fqrn = "#{@collection_namespace}.#{@collection_name}.#{@collection_role_identifier}" + "No resolution candidate for Ansible collection role: \"#{fqrn}\"" + end + + def message + <<~MESSAGE + No resolution candidate was found for the following Ansible collection role: + Collection namespace: #{@collection_namespace} + Collection name: #{@collection_name} + Role identifier: #{@collection_role_identifier} + Ensure the content source #{@content_source.cs_name} supplies this collection role. + Otherwise, it will be skipped. + MESSAGE + end + + def render_for_response + super.merge({ assignment_id: @assignment_id }) + end + end + end + end +end diff --git a/app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_role.rb b/app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_role.rb new file mode 100644 index 00000000..2eafad85 --- /dev/null +++ b/app/lib/foreman_ansible_director/issues/warnings/no_resolution_candidate_for_role.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Issues + module Warnings + class NoResolutionCandidateForRole < BaseWarning + def initialize(role_namespace:, + role_name:, + content_source:, + assignment_id:) + @role_namespace = role_namespace + @role_name = role_name + @assignment_id = assignment_id + super + end + + def title + fqrn = "#{@role_namespace}.#{@role_name}" + "No resolution candidate for Ansible role: \"#{fqrn}\"" + end + + def message + <<~MESSAGE + No resolution candidate was found for the following Ansible role: + Role namespace: #{@collection_namespace} + Role name: #{@collection_name} + Ensure the content source #{@content_source.cs_name} supplies this role. + Otherwise, it will be skipped. + MESSAGE + end + + def render_for_response + super.merge({ assignment_id: @assignment_id }) + end + end + end + end +end diff --git a/app/services/foreman_ansible_director/ansible_director_service.rb b/app/services/foreman_ansible_director/ansible_director_service.rb new file mode 100644 index 00000000..8f4489ac --- /dev/null +++ b/app/services/foreman_ansible_director/ansible_director_service.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + class AnsibleDirectorService + class << self + include ::ForemanAnsibleDirector::RequestCtx::RequestContextHelper + end + end +end diff --git a/app/services/foreman_ansible_director/assignment_service.rb b/app/services/foreman_ansible_director/assignment_service.rb index 970686b6..0d8b920e 100644 --- a/app/services/foreman_ansible_director/assignment_service.rb +++ b/app/services/foreman_ansible_director/assignment_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module ForemanAnsibleDirector - class AssignmentService + class AssignmentService < AnsibleDirectorService class << self def destroy_assignment(assignment) ActiveRecord::Base.transaction do @@ -158,7 +158,13 @@ def resolve_content_units(content_source, assignments) cuv: collection_role, } else - # TODO: Log warning + ctx.add_warning(::ForemanAnsibleDirector::Issues::Warnings::NoResolutionCandidateForCollectionRole.new( + collection_name: name, + collection_namespace: namespace, + collection_role_identifier: role_name, + content_source: content_source, + assignment_id: assignment.id + )) end when 'ForemanAnsibleDirector::AnsibleRole' @@ -171,7 +177,12 @@ def resolve_content_units(content_source, assignments) cuv: cu_version, } else - # TODO: Log warning + ctx.add_warning(::ForemanAnsibleDirector::Issues::Warnings::NoResolutionCandidateForRole.new( + role_name: name, + role_namespace: namespace, + content_source: content_source, + assignment_id: assignment.id + )) end end end diff --git a/app/services/foreman_ansible_director/request_ctx/request_context.rb b/app/services/foreman_ansible_director/request_ctx/request_context.rb new file mode 100644 index 00000000..38a2a989 --- /dev/null +++ b/app/services/foreman_ansible_director/request_ctx/request_context.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module RequestCtx + class RequestContext + class << self + def current + Thread.current[:ansible_director_request_context] + end + + def with_context(context) + old = Thread.current[:ansible_director_request_context] + Thread.current[:ansible_director_request_context] = context + yield + ensure + Thread.current[:ansible_director_request_context] = old + end + end + + attr_reader :errors, :warnings, :created, :updated, :deleted + + def initialize(coincidence_id) + @coincidence_id = coincidence_id + @errors = [] + @warnings = [] + @logs = [] + @created = [] + @updated = [] + @deleted = [] + end + + def response_status + return 'error' if @errors.any? + return 'warning' if @warnings.any? + 'success' + end + + def response_warnings + @warnings.map(&:render_for_response) + end + + def response_errors + @errors.map(&:render_for_response) + end + + def response_created + @created.group_by { |k| k.class.name.demodulize.underscore }.transform_values do |items| + items.map do |item| + { + id: item.id, + } + end + end + end + + def response_updated + @updated.group_by { |k| k.class.name.demodulize.underscore }.transform_values do |items| + items.map do |item| + { + id: item.id, + } + end + end + end + + def response_deleted + @deleted.group_by { |k| k.class.name.demodulize.underscore }.transform_values do |items| + items.map do |item| + { + id: item.id, + } + end + end + end + + def add_error(error) + @errors << error + end + + def add_warning(warning) + @warnings << warning + end + + def add_log_entry(log_entry, _level = 'info') + @logs << log_entry + end + + def add_created(record) + @created << record + end + + def add_updated(record) + @updated << record + end + + def add_deleted(record) + @deleted << record + end + end + end +end diff --git a/app/services/foreman_ansible_director/request_ctx/request_context_helper.rb b/app/services/foreman_ansible_director/request_ctx/request_context_helper.rb new file mode 100644 index 00000000..9c827065 --- /dev/null +++ b/app/services/foreman_ansible_director/request_ctx/request_context_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module RequestCtx + module RequestContextHelper + def ctx + ::ForemanAnsibleDirector::RequestCtx::RequestContext.current + end + end + end +end diff --git a/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl b/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl index 83122b27..6dd2b300 100644 --- a/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl +++ b/app/views/foreman_ansible_director/api/v2/assignments/assignments.json.rabl @@ -1,6 +1,6 @@ # frozen_string_literal: true -object @assignments +extends 'api/v2/common/response', object: @ctx node(:results) do { diff --git a/app/views/foreman_ansible_director/api/v2/common/response.json.rabl b/app/views/foreman_ansible_director/api/v2/common/response.json.rabl new file mode 100644 index 00000000..ffa4722b --- /dev/null +++ b/app/views/foreman_ansible_director/api/v2/common/response.json.rabl @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +node(:status) do + @ctx.response_status +end + +node(:errors) do + @ctx.response_errors +end + +node(:warnings) do + @ctx.response_warnings +end + +node(:updated) do + @ctx.updated +end + +node(:created) do + @ctx.response_created +end + +node(:deleted) do + @ctx.response_deleted +end From 060f0009eb6364ad3f8088507e3d58e7177254e6 Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Tue, 5 May 2026 16:37:18 +0200 Subject: [PATCH 4/6] wip: feat(logging): impl logging --- .../logging/loggable_model.rb | 33 ++++++++++++++ .../ansible_director_model.rb | 2 + .../logging/crud_logger.rb | 44 +++++++++++++++++++ .../request_ctx/request_context.rb | 8 ++-- lib/foreman_ansible_director/register.rb | 2 + 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 app/lib/foreman_ansible_director/logging/loggable_model.rb create mode 100644 app/services/foreman_ansible_director/logging/crud_logger.rb diff --git a/app/lib/foreman_ansible_director/logging/loggable_model.rb b/app/lib/foreman_ansible_director/logging/loggable_model.rb new file mode 100644 index 00000000..a4b2cc07 --- /dev/null +++ b/app/lib/foreman_ansible_director/logging/loggable_model.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Logging + module LoggableModel + extend ActiveSupport::Concern + + include ::ForemanAnsibleDirector::RequestCtx::RequestContextHelper + + included do + after_create :log_creation + after_update :log_update + after_destroy :log_destroy + end + + def loggable? + true + end + + def log_creation + ctx&.add_created(self) if loggable? + end + + def log_update + ctx&.add_updated(self) if !previous_changes.empty? && loggable? + end + + def log_destroy + ctx&.add_deleted(self) if loggable? + end + end + end +end diff --git a/app/models/foreman_ansible_director/ansible_director_model.rb b/app/models/foreman_ansible_director/ansible_director_model.rb index 2d2e4cc1..1b169426 100644 --- a/app/models/foreman_ansible_director/ansible_director_model.rb +++ b/app/models/foreman_ansible_director/ansible_director_model.rb @@ -5,6 +5,8 @@ class AnsibleDirectorModel < ApplicationRecord self.abstract_class = true self.table_name_prefix = 'ad_' + include ::ForemanAnsibleDirector::Logging::LoggableModel + def self.table_name table_name = model_name.route_key "#{table_name_prefix}#{table_name}" diff --git a/app/services/foreman_ansible_director/logging/crud_logger.rb b/app/services/foreman_ansible_director/logging/crud_logger.rb new file mode 100644 index 00000000..40d8dd52 --- /dev/null +++ b/app/services/foreman_ansible_director/logging/crud_logger.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ForemanAnsibleDirector + module Logging + class CrudLogger + class << self + def logger + ::Foreman::Logging.logger("#{::ForemanAnsibleDirector::Constants::PLUGIN_NAME}/crud") + end + + def log_record_creation(record) + base_msg = "[CREATE] #{record.class.name.demodulize}##{record.id}" + + logger.info(base_msg) + + return if Rails.env.production? + + debug_msg = "#{base_msg} | attributes: #{record.attributes.symbolize_keys.inspect}" + logger.debug(debug_msg) + end + + def log_record_update(record) + base_msg = "[UPDATE] #{record.class.name.demodulize}##{record.id}" + + logger.info(base_msg) + + return if Rails.env.production? + + debug_msg = begin + changes = record.previous_changes.transform_values { |(old, new)| [old, new] } + "#{base_msg} | changes: #{changes.to_h.inspect}" + end + logger.debug(debug_msg) + end + + def log_record_deletion(record) + base_msg = "[DELETE] #{record.class.name.demodulize}##{record.id}" + + logger.info(base_msg) + end + end + end + end +end diff --git a/app/services/foreman_ansible_director/request_ctx/request_context.rb b/app/services/foreman_ansible_director/request_ctx/request_context.rb index 38a2a989..f7d3f01f 100644 --- a/app/services/foreman_ansible_director/request_ctx/request_context.rb +++ b/app/services/foreman_ansible_director/request_ctx/request_context.rb @@ -23,7 +23,6 @@ def initialize(coincidence_id) @coincidence_id = coincidence_id @errors = [] @warnings = [] - @logs = [] @created = [] @updated = [] @deleted = [] @@ -81,19 +80,18 @@ def add_warning(warning) @warnings << warning end - def add_log_entry(log_entry, _level = 'info') - @logs << log_entry - end - def add_created(record) + ::ForemanAnsibleDirector::Logging::CrudLogger.log_record_creation(record) @created << record end def add_updated(record) + ::ForemanAnsibleDirector::Logging::CrudLogger.log_record_update(record) @updated << record end def add_deleted(record) + ::ForemanAnsibleDirector::Logging::CrudLogger.log_record_deletion(record) @deleted << record end end diff --git a/lib/foreman_ansible_director/register.rb b/lib/foreman_ansible_director/register.rb index 7dca3d75..9db6a41c 100644 --- a/lib/foreman_ansible_director/register.rb +++ b/lib/foreman_ansible_director/register.rb @@ -254,4 +254,6 @@ extend_rabl_template 'api/v2/hosts/main', '/api/v2/hosts/ansible_lifecycle_environment' parameter_filter Host, :ansible_lifecycle_environment_id + + logger :crud, enabled: true end From 7246c56ab254b9041e3cd56702505f0db1d519ce Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Mon, 11 May 2026 16:07:57 +0200 Subject: [PATCH 5/6] refactor: type id as number instead of string Before this commit, the "Identifiable" interface typed "id" as string when, in reality, id is a number. This did not cause issues for most usages, as the value was used for string interpolation, however id-id comparisons failed because they tried comparing "1" to 1, i.e. a number to a string. This is fixed by correctly typing "id" as number. --- .../ansible_content/components/AnsibleContentTable.tsx | 6 +++--- .../components/AnsibleContentTableSecondaryRow.tsx | 4 ++-- .../AnsibleVariablesOverview/AnsibleVariablesOverview.tsx | 2 +- .../AnsibleVariablesOverview/AnsibleVariablesSelector.tsx | 4 ++-- .../HostDetailsTab/components/LceAssignmentSelector.tsx | 2 +- webpack/types/AnsibleExecutionEnvTypes.d.ts | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/webpack/components/ansible_content/components/AnsibleContentTable.tsx b/webpack/components/ansible_content/components/AnsibleContentTable.tsx index aeea932a..6c3145f7 100644 --- a/webpack/components/ansible_content/components/AnsibleContentTable.tsx +++ b/webpack/components/ansible_content/components/AnsibleContentTable.tsx @@ -40,7 +40,7 @@ export const AnsibleContentTable: React.FC = ({ setExpandedDetailsNodeNames, ] = React.useState([]); - const [selectedVersionId, setSelectedVersionId] = React.useState(''); + const [selectedVersionId, setSelectedVersionId] = React.useState(-1); const [selectedIdentifier, setSelectedIdentifier] = React.useState( '' ); @@ -126,13 +126,13 @@ export const AnsibleContentTable: React.FC = ({ {renderRows(apiResponse.results)} - {selectedVersionId !== '' && ( + {selectedVersionId !== -1 && ( <> setSelectedVersionId('')} + onClose={() => setSelectedVersionId(-1)} setSelectedVariable={setSelectedVariable} /> {selectedVariable && ( diff --git a/webpack/components/ansible_content/components/AnsibleContentTableSecondaryRow.tsx b/webpack/components/ansible_content/components/AnsibleContentTableSecondaryRow.tsx index 05b7dee3..826e7a50 100644 --- a/webpack/components/ansible_content/components/AnsibleContentTableSecondaryRow.tsx +++ b/webpack/components/ansible_content/components/AnsibleContentTableSecondaryRow.tsx @@ -23,10 +23,10 @@ import { AdPermissions } from '../../../constants/foremanAnsibleDirectorPermissi interface AnsibleContentTableSecondaryRowProps { identifier: string; // Needed for keys - nodeId: string; + nodeId: number; nodeVersions: AnsibleContentVersionWithCount[]; isExpanded: boolean; - setSelectedVersionId: Dispatch>; + setSelectedVersionId: Dispatch>; setSelectedIdentifier: Dispatch>; setSelectedVersion: Dispatch>; setIsConfirmationModalOpen: Dispatch>; diff --git a/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesOverview.tsx b/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesOverview.tsx index 06e2e685..04e537cc 100644 --- a/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesOverview.tsx +++ b/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesOverview.tsx @@ -17,7 +17,7 @@ import { AnsibleVariablesSelector } from './AnsibleVariablesSelector'; import { AnsibleVariable } from '../../../../types/AnsibleVariableTypes'; interface AnsibleVariablesDetailProps { - selectedVersionId: string; + selectedVersionId: number; selectedVersion: string; selectedIdentifier: string; onClose: () => void; diff --git a/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesSelector.tsx b/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesSelector.tsx index 9ac3237e..c74ac181 100644 --- a/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesSelector.tsx +++ b/webpack/components/ansible_content/components/AnsibleVariablesOverview/AnsibleVariablesSelector.tsx @@ -64,7 +64,7 @@ export const AnsibleVariablesSelector = ({ const [roleFilter, setRoleFilter] = React.useState(''); const [variableFilter, setVariableFilter] = React.useState(''); - const [variableUpdating, setVariableUpdating] = React.useState(''); + const [variableUpdating, setVariableUpdating] = React.useState(-1); const userCanEditVariables: boolean = usePermissions([ AdPermissions.ansibleVariables.edit, @@ -118,7 +118,7 @@ export const AnsibleVariablesSelector = ({ }) ); } finally { - setVariableUpdating(''); + setVariableUpdating(-1); } }; diff --git a/webpack/components/extensions/host_details/HostDetailsTab/components/LceAssignmentSelector.tsx b/webpack/components/extensions/host_details/HostDetailsTab/components/LceAssignmentSelector.tsx index 7d7f4837..743ec9dc 100644 --- a/webpack/components/extensions/host_details/HostDetailsTab/components/LceAssignmentSelector.tsx +++ b/webpack/components/extensions/host_details/HostDetailsTab/components/LceAssignmentSelector.tsx @@ -209,7 +209,7 @@ export const InnerLceAssignmentSelector = ({ : true; const treeNode: ContentUnitTreeItemData = { - id: node.id, + id: node.id.toString(), text: node.name, isChecked, checkProps: { 'aria-label': `Select ${node.name}` }, diff --git a/webpack/types/AnsibleExecutionEnvTypes.d.ts b/webpack/types/AnsibleExecutionEnvTypes.d.ts index 69ef7a6b..a02e3724 100644 --- a/webpack/types/AnsibleExecutionEnvTypes.d.ts +++ b/webpack/types/AnsibleExecutionEnvTypes.d.ts @@ -1,7 +1,7 @@ import { AnsibleContentUnitAssignment } from './AnsibleContentTypes'; export interface Identifiable { - id: string; + id: number; } export interface AnsibleExecutionEnvBase { From b4cda99d6dd8775f12e7e01a50b641951b6d085b Mon Sep 17 00:00:00 2001 From: Thorben Denzer Date: Wed, 13 May 2026 18:11:14 +0200 Subject: [PATCH 6/6] wip: feat(ui/assignments): unified assignment interface --- webpack/components/common/AlertModal.tsx | 51 +++ .../AnsibleContentAssignment.tsx | 344 ++++++++++++++++++ .../AnsibleContentAssignmentWrapper.tsx | 82 +++++ .../components/AssignmentSelector.tsx | 279 ++++++++++++++ .../components/AssignmentSelectorWrapper.tsx | 136 +++++++ .../components/ContentAssignmentTable.tsx | 255 +++++++++++++ .../components/HierarchyLevelSelector.tsx | 44 +++ .../AnsibleContentAssignment/helpers.ts | 39 ++ .../HostDetailsTab/HostDetailsTab.tsx | 68 +--- webpack/helpers/comparisons.ts | 6 + .../typeGuards/contentAssignmentTypeGuards.ts | 22 ++ .../types/AnsibleContentAssignmentTypes.d.ts | 65 ++++ webpack/types/AnsibleContentTypes.d.ts | 2 + webpack/types/common.d.ts | 28 ++ webpack/types/issues/errors.d.ts | 4 + webpack/types/issues/warnings.d.ts | 9 + 16 files changed, 1373 insertions(+), 61 deletions(-) create mode 100644 webpack/components/common/AlertModal.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignment.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignmentWrapper.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/components/AssignmentSelector.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/components/AssignmentSelectorWrapper.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/components/ContentAssignmentTable.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/components/HierarchyLevelSelector.tsx create mode 100644 webpack/components/common/AnsibleContentAssignment/helpers.ts create mode 100644 webpack/helpers/comparisons.ts create mode 100644 webpack/helpers/typeGuards/contentAssignmentTypeGuards.ts create mode 100644 webpack/types/AnsibleContentAssignmentTypes.d.ts create mode 100644 webpack/types/common.d.ts create mode 100644 webpack/types/issues/errors.d.ts create mode 100644 webpack/types/issues/warnings.d.ts diff --git a/webpack/components/common/AlertModal.tsx b/webpack/components/common/AlertModal.tsx new file mode 100644 index 00000000..e0eb5be2 --- /dev/null +++ b/webpack/components/common/AlertModal.tsx @@ -0,0 +1,51 @@ +import React, { ReactElement } from 'react'; +import { + Modal, + Button, + ModalVariant, + Alert, + CodeBlock, + CodeBlockCode, +} from '@patternfly/react-core'; +import { pfAlertVariant } from '../../types/common'; + +interface AlertModalProps { + variant: pfAlertVariant; + isOpen: boolean; + onClose: () => void; + title: string; + message: string; +} + +export const AlertModal = ({ + variant, + isOpen, + onClose, + message, + title, +}: AlertModalProps): ReactElement => ( + <> + + } + isOpen={isOpen} + onClose={onClose} + actions={[ + , + ]} + > + + {message} + + + +); diff --git a/webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignment.tsx b/webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignment.tsx new file mode 100644 index 00000000..a1292d62 --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignment.tsx @@ -0,0 +1,344 @@ +/* eslint-disable max-lines */ +import React, { ReactElement } from 'react'; +import axios, { AxiosResponse } from 'axios'; +import { useDispatch } from 'react-redux'; + +import Permitted from 'foremanReact/components/Permitted'; +import { addToast } from 'foremanReact/components/ToastsList'; +import { foremanUrl } from 'foremanReact/common/helpers'; +import { translate as _ } from 'foremanReact/common/I18n'; + +import { + Button, + Flex, + FlexItem, + Level, + LevelItem, + SearchInput, + Tab, + Tabs, + TextContent, + Text, + TextVariants, + Toolbar, + ToolbarContent, + ToolbarItem, + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + Bullseye, + EmptyStateFooter, + EmptyStateActions, +} from '@patternfly/react-core'; + +import ClusterIcon from '@patternfly/react-icons/dist/esm/icons/cluster-icon'; +import ObjectGroupIcon from '@patternfly/react-icons/dist/esm/icons/object-group-icon'; +import ExternalLinkSquareAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-square-alt-icon'; +import ResourcesEmptyIcon from '@patternfly/react-icons/dist/esm/icons/resources-empty-icon'; + +import { AdPermissions } from '../../../constants/foremanAnsibleDirectorPermissions'; + +import { ContentAssignmentTable } from './components/ContentAssignmentTable'; +import { + AnsibleContentAssignment, + ContentResolutionNode, + ContentResolutionNodeType, + ResolvedAssignment, +} from '../../../types/AnsibleContentAssignmentTypes'; +import { HierarchyLevelSelector } from './components/HierarchyLevelSelector'; +import { AlertModal } from '../AlertModal'; +import { assignmentFqrn } from './helpers'; +import { AssignmentSelectorWrapper } from './components/AssignmentSelectorWrapper'; +import { ResolutionWarning } from '../../../types/issues/warnings'; + +interface AnsibleContentAssignmentCompProps { + crnId: number; + crnType: ContentResolutionNodeType; + crnName: string; + csId: number; + hierarchy: ContentResolutionNode[]; + assignments: ResolvedAssignment[]; + warnings: ResolutionWarning[]; + onResolveClick: () => void; +} + +const hierarchyIconMap: Record = { + Host: , + Hostgroup: , +}; + +export const AnsibleContentAssignmentComp = ({ + crnId, + crnType, + crnName, + csId, + hierarchy, + assignments, + warnings, + onResolveClick, +}: AnsibleContentAssignmentCompProps): ReactElement => { + const [ + selectedAlert, + setSelectedAlert, + ] = React.useState(null); + + const [isAssignmentModalOpen, setIsAssignmentModalOpen] = React.useState< + boolean + >(false); + const [activeTabKey, setActiveTabKey] = React.useState(0); + + const [fqrnFilter, setFqrnFilter] = React.useState(''); + + const [selectedHierarchyNode, setSelectedHierarchyNode] = React.useState< + ContentResolutionNode + >({ + type: crnType, + id: crnId, + name: crnName, + }); + + const dispatch = useDispatch(); + + const handleUnassign = async ( + assignment: ResolvedAssignment + ): Promise => { + const fqrn = assignmentFqrn(assignment); + try { + await axios.delete( + foremanUrl(`/api/v2/ansible_director/assignments/${assignment.id}`), + {} + ); + dispatch( + addToast({ + type: 'success', + key: `DELETE_ASSIGNMENT_${assignment.id}_SUCC`, + message: `Successfully unassigned "${fqrn}"!`, + sticky: false, + }) + ); + } catch (e) { + dispatch( + addToast({ + type: 'danger', + key: `DELETE_ASSIGNMENT_${assignment.id}_ERR`, + message: `Unassigning Ansible role"${fqrn}" failed with error code "${ + (e as { response: AxiosResponse }).response.status + }".`, + sticky: false, + }) + ); + } + }; + + const filteredAssignments = (): ResolvedAssignment< + AnsibleContentAssignment + >[] => { + const startIndex = hierarchy.findIndex( + node => + node.type === selectedHierarchyNode.type && + node.id === selectedHierarchyNode.id + ); + + const relevantNodes = hierarchy.slice(startIndex); + + let filtered: Array> = []; + + relevantNodes.forEach(hierarchyNode => { + filtered = [ + ...assignments.filter( + assignment => + assignment.consumable_type === hierarchyNode.type && + assignment.consumable_id === hierarchyNode.id + ), + ...filtered, + ].filter(assignment => + assignmentFqrn(assignment).startsWith(fqrnFilter.toLowerCase()) + ); + }); + + return filtered; + }; + + const mainContent = (): ReactElement => { + const filtered = filteredAssignments(); + + if (filtered.length > 0) { + return ( + { + const warning = warnings.find( + w => w.assignment_id === assignment.id + ); + setSelectedAlert(warning === undefined ? null : warning); + }} + onAssignmentRemove={async assignment => { + await handleUnassign(assignment); + onResolveClick(); + }} + /> + ); + } + return ( + + + 0 + ? _( + 'No content found for these filters. Clear all filters and try again.' + ) + : _('No content assigned to this host.') + } + headingLevel="h1" + icon={} + /> + {assignments.length > 0 ? ( + + + + + + ) : null} + + + ); + }; + + return ( + setActiveTabKey(eventKey as number)} + isBox + isFilled + > + + +
+ + + + + <> + + + Content source (LCE): + + + + + + + <> + + + Inheritance hierarchy: + + + setSelectedHierarchyNode(crn)} + selected={selectedHierarchyNode} + /> + + + + + + + setFqrnFilter(value)} + /> + + + + + + + + + + + + + + {mainContent()} + + {selectedAlert !== null && ( + setSelectedAlert(null)} + title={selectedAlert.title} + message={selectedAlert.message} + /> + )} + {isAssignmentModalOpen && ( + setIsAssignmentModalOpen(false)} + onAbort={() => setIsAssignmentModalOpen(false)} + onSuccess={onResolveClick} + /> + )} +
+
+
+ + variables + +
+ ); +}; diff --git a/webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignmentWrapper.tsx b/webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignmentWrapper.tsx new file mode 100644 index 00000000..bbde339a --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/AnsibleContentAssignmentWrapper.tsx @@ -0,0 +1,82 @@ +import React, { ReactElement } from 'react'; + +import { useAPI } from 'foremanReact/common/hooks/API/APIHooks'; +import { + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + Spinner, +} from '@patternfly/react-core'; +import { translate as _ } from 'foremanReact/common/I18n'; +import { + AnsibleContentAssignment, + ContentResolutionNode, + ContentResolutionNodeType, + ResolvedAssignment, +} from '../../../types/AnsibleContentAssignmentTypes'; +import { AnsibleContentAssignmentComp } from './AnsibleContentAssignment'; +import { DefaultResponse } from '../../../types/common'; +import { AnsibleDirectorError } from '../../../types/issues/errors'; +import { ResolutionWarning } from '../../../types/issues/warnings'; +import { crnTypeUrlMap } from './helpers'; + +interface AnsibleContentAssignmentWrapperProps { + crnId: number; + crnType: ContentResolutionNodeType; + crnName: string; + csId: number; +} + +interface GetCrnAssignmentsResponse { + assignments: ResolvedAssignment[]; + hierarchy: ContentResolutionNode[]; +} + +export const AnsibleContentAssignmentWrapper = ({ + crnType, + crnId, + crnName, + csId, +}: AnsibleContentAssignmentWrapperProps): ReactElement => { + const getCrnAssignmentsRequest = useAPI< + DefaultResponse< + AnsibleDirectorError, + ResolutionWarning, + GetCrnAssignmentsResponse + > + >( + 'get', + `/api/v2/ansible_director/assignments/${crnTypeUrlMap[crnType]}/${crnId}?resolve=true` + ); + + const refreshRequest = (): void => { + getCrnAssignmentsRequest.setAPIOptions(options => ({ ...options })); + }; + + if (getCrnAssignmentsRequest.status === 'ERROR') { + // TODO: Handle + } else if (getCrnAssignmentsRequest.status === 'RESOLVED') { + return ( + refreshRequest()} + /> + ); + } + + return ( + + } + /> + + ); +}; diff --git a/webpack/components/common/AnsibleContentAssignment/components/AssignmentSelector.tsx b/webpack/components/common/AnsibleContentAssignment/components/AssignmentSelector.tsx new file mode 100644 index 00000000..80ba083d --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/components/AssignmentSelector.tsx @@ -0,0 +1,279 @@ +import React, { ReactElement, useMemo } from 'react'; +import '@patternfly/react-core/dist/styles/base.css'; + +import { + Table, + Tr, + Tbody, + Td, + TreeRowWrapper, + TdProps, + OuterScrollContainer, + InnerScrollContainer, +} from '@patternfly/react-table'; + +import CubesIcon from '@patternfly/react-icons/dist/esm/icons/cubes-icon'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; +import { + AnsibleCollectionRole, + FullAnsibleContentUnitAssignment, +} from '../../../../types/AnsibleContentTypes'; +import { + AnsibleCollectionRoleAssignment, + AnsibleContentAssignment, + AnsibleContentAssignmentCreate, + AnsibleRoleAssignment, + ResolvedAssignment, +} from '../../../../types/AnsibleContentAssignmentTypes'; +import { assignmentFqrn } from '../helpers'; + +interface AssignmentSelectorProps { + availableContent: FullAnsibleContentUnitAssignment[]; + excludeAssignments: ResolvedAssignment[]; + selected: AnsibleContentAssignmentCreate[]; + onChange: ( + newAssignables: AnsibleContentAssignmentCreate[] + ) => void; +} + +export const AssignmentSelector = ({ + availableContent, + excludeAssignments, + selected, + onChange, +}: AssignmentSelectorProps): ReactElement => { + const [expandedFqrns, setExpandedFqrns] = React.useState([]); + + const assignableFqrns = useMemo( + () => selected.map(assignable => assignmentFqrn(assignable)), + [selected] + ); + + const excludeFqrns = useMemo( + () => excludeAssignments.map(assignment => assignmentFqrn(assignment)), + [excludeAssignments] + ); + + const renderAcuRows = ( + [node, ...remainingNodes]: FullAnsibleContentUnitAssignment[], + level = 1, + posinset = 1, + rowIndex = 0, + isHidden = false + ): React.ReactNode[] => { + if (!node) { + return []; + } + const isExpanded = expandedFqrns.includes(node.id); + let isChecked: boolean | null; + + // TODO: Optimization potential + const fqrns = node.roles.map(r => `${node.identifier}.${r.name}`); + + if (fqrns.every(fqrn => assignableFqrns.includes(fqrn))) { + isChecked = true; + } else if (fqrns.some(fqrn => assignableFqrns.includes(fqrn))) { + isChecked = null; + } else { + isChecked = false; + } + const icon = node.type === 'collection' ? : ; + + const treeRow: TdProps['treeRow'] = { + onCollapse: () => { + if (isExpanded) { + const filtered = expandedFqrns.filter(n => n !== node.id); + setExpandedFqrns([...filtered]); + } else { + setExpandedFqrns([...expandedFqrns, node.id]); + } + }, + onCheckChange: () => onAcuSelect(isChecked, node), + rowIndex, + props: { + isExpanded, + isHidden, + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': node.roles ? node.roles.length : 0, + isChecked, + checkboxId: `checkbox_id_${node.identifier + .toLowerCase() + .replace(/\s+/g, '_')}`, + icon, + }, + }; + + const childRows = + node.roles && node.roles.length + ? renderAcrRows( + node.roles, + node, + level + 1, + 1, + rowIndex + 1, + !isExpanded || isHidden + ) + : []; + + return [ + + {node.identifier} + , + ...childRows, + ...renderAcuRows( + remainingNodes, + level, + posinset + 1, + rowIndex + 1 + childRows.length, + isHidden + ), + ]; + }; + + const renderAcrRows = ( + [node, ...remainingNodes]: AnsibleCollectionRole[], + parent: FullAnsibleContentUnitAssignment, + level = 1, + posinset = 1, + rowIndex = 0, + isHidden = false + ): React.ReactNode[] => { + if (!node) { + return []; + } + const fqrn = `${parent.identifier}.${node.name}`; + + const isChecked = assignableFqrns.filter(n => n === fqrn).length > 0; + const icon = ; + + const treeRow: TdProps['treeRow'] = { + onCollapse: () => {}, + onCheckChange: () => onAcrSelect(isChecked, parent, node), + rowIndex, + props: { + 'aria-level': level, + 'aria-posinset': posinset, + 'aria-setsize': 0, + isChecked, + checkboxId: `checkbox_id_${node.name + .toLowerCase() + .replace(/\s+/g, '_')}`, + icon, + }, + }; + + return [ + onAcrSelect(isChecked, parent, node)} + > + {node.name} + , + ...renderAcrRows( + remainingNodes, + parent, + level, + posinset + 1, + rowIndex + 1, + isHidden + ), + ]; + }; + + const onAcuSelect = ( + isChecked: boolean | null, + acu: FullAnsibleContentUnitAssignment + ): void => { + switch (isChecked) { + case false: + { + let newAssignables; + if (acu.type === 'collection') { + const splitIdentifier = acu.identifier.split('.'); + newAssignables = acu.roles.map( + collectionRole => + ({ + assignable_type: + 'ForemanAnsibleDirector::AnsibleCollectionRole', + assignable_namespace: splitIdentifier[0], + assignable_name: splitIdentifier[1], + assignable_role_name: collectionRole.name, + } as AnsibleContentAssignmentCreate< + AnsibleCollectionRoleAssignment + >) + ); + } else { + const splitIdentifier = acu.identifier.split('.'); + newAssignables = [ + { + assignable_type: 'ForemanAnsibleDirector::AnsibleRole', + assignable_namespace: splitIdentifier[0], + assignable_name: splitIdentifier[1], + } as AnsibleContentAssignmentCreate, + ]; + } + + onChange([...selected, ...newAssignables]); + } + break; + default: + // true || null + { + let filterFqrns; + + if (acu.type === 'collection') { + filterFqrns = acu.roles.map( + collectionRole => `${acu.identifier}.${collectionRole.name}` + ); + } else { + filterFqrns = [acu.identifier]; + } + + onChange([ + ...selected.filter(n => !filterFqrns.includes(assignmentFqrn(n))), + ]); + } + break; + } + }; + + const onAcrSelect = ( + isChecked: boolean, + acu: FullAnsibleContentUnitAssignment, + acr: AnsibleCollectionRole + ): void => { + isChecked + ? onChange([ + ...selected.filter( + assignable => + assignmentFqrn(assignable) !== `${acu.identifier}.${acr.name}` + ), + ]) + : onChange([ + ...selected, + { + assignable_type: 'ForemanAnsibleDirector::AnsibleCollectionRole', + assignable_namespace: acu.identifier.split('.')[0], + assignable_name: acu.identifier.split('.')[1], + assignable_role_name: acr.name, + } as AnsibleContentAssignmentCreate, + ]); + }; + + return ( +
+ + + + {renderAcuRows(availableContent)} +
+
+
+
+ ); +}; diff --git a/webpack/components/common/AnsibleContentAssignment/components/AssignmentSelectorWrapper.tsx b/webpack/components/common/AnsibleContentAssignment/components/AssignmentSelectorWrapper.tsx new file mode 100644 index 00000000..9734c039 --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/components/AssignmentSelectorWrapper.tsx @@ -0,0 +1,136 @@ +import React, { ReactElement } from 'react'; +import axios, { AxiosResponse } from 'axios'; +import { useDispatch } from 'react-redux'; + +import { useAPI, UseAPIReturn } from 'foremanReact/common/hooks/API/APIHooks'; +import { translate as _ } from 'foremanReact/common/I18n'; +import { addToast } from 'foremanReact/components/ToastsList'; + +import { + Button, + EmptyState, + EmptyStateHeader, + EmptyStateIcon, + Modal, + ModalVariant, + Spinner, +} from '@patternfly/react-core'; + +import { foremanUrl } from 'foremanReact/common/helpers'; +import { DenseAnsibleLce } from '../../../../types/AnsibleEnvironmentsTypes'; +import { AssignmentSelector } from './AssignmentSelector'; +import { + AnsibleContentAssignment, + AnsibleContentAssignmentCreate, + ContentResolutionNodeType, + ResolvedAssignment, +} from '../../../../types/AnsibleContentAssignmentTypes'; +import { crnTypeUrlMap } from '../helpers'; + +interface AssignmentSelectorWrapperProps { + crnId: number; + crnType: ContentResolutionNodeType; + csId: number; + excludeAssignments: ResolvedAssignment[]; + onClose: () => void; + onAbort: () => void; + onSuccess: () => void; +} + +export const AssignmentSelectorWrapper = ({ + crnId, + crnType, + csId, + excludeAssignments, + onAbort, + onClose, + onSuccess, +}: AssignmentSelectorWrapperProps): ReactElement => { + const [selectedAssignables, setSelectedAssignables] = React.useState< + AnsibleContentAssignmentCreate[] + >([]); + + const dispatch = useDispatch(); + + const getAvailableContentRequest: UseAPIReturn = useAPI< + DenseAnsibleLce + >( + 'get', + foremanUrl(`/api/v2/ansible_director/lifecycle_environments/${csId}/`) + ); + + const handleAssignmentConfirm = async (): Promise => { + try { + await axios.post( + foremanUrl( + `/api/v2/ansible_director/assignments/${crnTypeUrlMap[crnType]}/${crnId}` + ), + { assignments: selectedAssignables } + ); + dispatch( + addToast({ + type: 'success', + key: 'CREATE_ASSIGNMENTS_HOST_1_SUCC', + message: `Successfully assigned ${selectedAssignables.length} roles to bla!`, + sticky: false, + }) + ); + } catch (e) { + dispatch( + addToast({ + type: 'danger', + key: 'CREATE_ASSIGNMENTS_HOST_1_ERR', + message: `Assigning Ansible roles to bla failed with error code "${ + (e as { response: AxiosResponse }).response.status + }".`, + sticky: false, + }) + ); + } + }; + + if (getAvailableContentRequest.status === 'RESOLVED') { + return ( + { + await handleAssignmentConfirm(); + onSuccess(); + }} + > + Confirm + , + , + ]} + > + setSelectedAssignables(newAssignables)} + /> + + ); + } else if (getAvailableContentRequest.status === 'ERROR') { + // TODO: Handle + } + + return ( + + } + /> + + ); +}; diff --git a/webpack/components/common/AnsibleContentAssignment/components/ContentAssignmentTable.tsx b/webpack/components/common/AnsibleContentAssignment/components/ContentAssignmentTable.tsx new file mode 100644 index 00000000..7480f0e0 --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/components/ContentAssignmentTable.tsx @@ -0,0 +1,255 @@ +import React, { ReactElement, ReactNode } from 'react'; +import { + Table, + Thead, + Tr, + Th, + Tbody, + Td, + OuterScrollContainer, + InnerScrollContainer, + ThProps, + IAction, + ActionsColumn, +} from '@patternfly/react-table'; + +import { Icon, Label } from '@patternfly/react-core'; +import CubesIcon from '@patternfly/react-icons/dist/esm/icons/cubes-icon'; +import CubeIcon from '@patternfly/react-icons/dist/esm/icons/cube-icon'; +import { + AnsibleContentAssignment, + ContentResolutionNode, + ContentResolutionNodeType, + ResolvedAssignment, +} from '../../../../types/AnsibleContentAssignmentTypes'; + +import { assignmentFqrn, crColorHierarchy } from '../helpers'; + +interface ContentAssignmentTableProps { + crnType: ContentResolutionNodeType; + crnId: number; + assignments: Array>; + hierarchy: ContentResolutionNode[]; + hierarchyIconMap: Record; + onBadAssignmentClick: ( + assignment: ResolvedAssignment + ) => void; + onAssignmentRemove: ( + assignment: ResolvedAssignment + ) => void; +} + +const indexMap = { + type: 0, + fqrn: 1, + inherited_by: 2, +}; + +export const ContentAssignmentTable = ({ + crnType, + crnId, + assignments, + hierarchy, + hierarchyIconMap, + onBadAssignmentClick, + onAssignmentRemove, +}: ContentAssignmentTableProps): ReactElement => { + const [activeSortKey, setActiveSortKey] = React.useState< + 'type' | 'fqrn' | 'inherited_by' + >('fqrn'); + + const [activeSortDirection, setActiveSortDirection] = React.useState< + 'asc' | 'desc' | undefined + >('desc'); + + const sortPredicate = ( + sortKey: 'type' | 'fqrn' | 'inherited_by', + direction?: 'desc' | 'asc' + ): (( + a: ResolvedAssignment, + b: ResolvedAssignment + ) => number) => { + switch (sortKey) { + case 'type': + if (direction === 'desc') { + return (a, b) => a.assignable_type.localeCompare(b.assignable_type); + } + return (a, b) => b.assignable_type.localeCompare(a.assignable_type); + case 'fqrn': + if (direction === 'desc') { + return (a, b) => { + const firstFqrn = assignmentFqrn(a); + const secondFqrn = assignmentFqrn(b); + return firstFqrn.localeCompare(secondFqrn); + }; + } + return (a, b) => { + const firstFqrn = assignmentFqrn(a); + const secondFqrn = assignmentFqrn(b); + return secondFqrn.localeCompare(firstFqrn); + }; + case 'inherited_by': + if (direction === 'desc') { + return (a, b) => { + const firstHieraNode = hierarchy.find( + n => n.type === a.consumable_type && n.id === a.consumable_id + ); + const secondHieraNode = hierarchy.find( + n => n.type === b.consumable_type && n.id === b.consumable_id + ); + return firstHieraNode && secondHieraNode + ? firstHieraNode.name.localeCompare(secondHieraNode.name) + : 0; + }; + } + return (a, b) => { + const firstHieraNode = hierarchy.find( + n => n.type === a.consumable_type && n.id === a.consumable_id + ); + const secondHieraNode = hierarchy.find( + n => n.type === b.consumable_type && n.id === b.consumable_id + ); + return firstHieraNode && secondHieraNode + ? secondHieraNode.name.localeCompare(firstHieraNode.name) + : 0; + }; + default: + return (a, b) => { + const firstFqrn = assignmentFqrn(a); + const secondFqrn = assignmentFqrn(b); + return firstFqrn.localeCompare(secondFqrn); + }; + } + }; + + let sortedAssignments = assignments; + if (activeSortKey !== undefined) { + sortedAssignments = assignments.sort((a, b) => + sortPredicate(activeSortKey, activeSortDirection)(a, b) + ); + } + + const getSortParams = ( + columnKey: keyof typeof indexMap + ): ThProps['sort'] => ({ + sortBy: { + index: indexMap[activeSortKey], + direction: activeSortDirection, + defaultDirection: 'desc', + }, + onSort: (_event, _index, direction) => { + setActiveSortKey(columnKey); + setActiveSortDirection(direction); + }, + columnIndex: indexMap[columnKey], + }); + + const hierarchyNode = ( + assignment: ResolvedAssignment + ): ReactNode => { + const node = hierarchy.find( + n => + n.type === assignment.consumable_type && + n.id === assignment.consumable_id + ); + + if (node === undefined) { + return null; + } + + return ( + + ); + }; + + const columnNames = { + type: 'Type', + fqrn: 'Name', + donor: 'Donor', + resolved: 'Resolved version', + }; + + const rowActions = ( + assignment: ResolvedAssignment + ): IAction[] => [ + { title: 'Unassign', onClick: () => onAssignmentRemove(assignment) }, + ]; + + return ( +
+ + + + + + + + + + + + {sortedAssignments.map((assignment, rowIndex) => ( + + + + + + + + + + ))} +
+ {columnNames.type} + {columnNames.fqrn} + {columnNames.donor} + {columnNames.resolved} +
+ + {assignment.assignable_type === + 'ForemanAnsibleDirector::AnsibleRole' ? ( + + ) : ( + + )} + + + {assignmentFqrn(assignment)} + + {hierarchyNode(assignment)} + + {assignment.resolved !== null ? ( + + ) : ( + + )} + + +
+
+
+
+ ); +}; diff --git a/webpack/components/common/AnsibleContentAssignment/components/HierarchyLevelSelector.tsx b/webpack/components/common/AnsibleContentAssignment/components/HierarchyLevelSelector.tsx new file mode 100644 index 00000000..a319a3b0 --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/components/HierarchyLevelSelector.tsx @@ -0,0 +1,44 @@ +import React, { ReactElement } from 'react'; +import { Breadcrumb, BreadcrumbItem, Label } from '@patternfly/react-core'; +import { + ContentResolutionNode, + ContentResolutionNodeType, +} from '../../../../types/AnsibleContentAssignmentTypes'; + +import { equalsCrn } from '../../../../helpers/comparisons'; +import { crColorHierarchy } from '../helpers'; + +interface HierarchyLevelSelectorProps { + hierarchy: ContentResolutionNode[]; + hierarchyIconMap: Record; + selected: ContentResolutionNode; + onSelect: (crn: ContentResolutionNode) => void; +} + +export const HierarchyLevelSelector = ({ + hierarchy, + hierarchyIconMap, + onSelect, + selected, +}: HierarchyLevelSelectorProps): ReactElement => ( + + {hierarchy.map((crn, index) => ( + + + + ))} + +); diff --git a/webpack/components/common/AnsibleContentAssignment/helpers.ts b/webpack/components/common/AnsibleContentAssignment/helpers.ts new file mode 100644 index 00000000..c15ebdcd --- /dev/null +++ b/webpack/components/common/AnsibleContentAssignment/helpers.ts @@ -0,0 +1,39 @@ +import { + AnsibleCollectionRoleAssignment, + AnsibleContentAssignment, + AnsibleContentAssignmentCreate, + AnsibleRoleAssignment, + ContentResolutionNodeType, + Fqrnable, + ResolvedAssignment, +} from '../../../types/AnsibleContentAssignmentTypes'; +import { pfLabelColorType } from '../../../types/common'; + +export const assignmentFqrn = (assignment: Fqrnable): string => { + if ( + assignment.assignable_type === + 'ForemanAnsibleDirector::AnsibleCollectionRole' + ) { + const acr = assignment as + | AnsibleCollectionRoleAssignment + | AnsibleContentAssignmentCreate + | ResolvedAssignment; + return `${acr.assignable_namespace}.${acr.assignable_name}.${acr.assignable_role_name}`; + } else if ( + assignment.assignable_type === 'ForemanAnsibleDirector::AnsibleRole' + ) { + const role = assignment as + | AnsibleRoleAssignment + | AnsibleContentAssignmentCreate + | ResolvedAssignment; + return `${role.assignable_namespace}.${role.assignable_name}`; + } + + return ''; +}; + +export const crnTypeUrlMap: Record = { + Host: 'host', + Hostgroup: 'hostgroup', +}; +export const crColorHierarchy: pfLabelColorType[] = ['gold', 'purple', 'cyan']; diff --git a/webpack/components/extensions/host_details/HostDetailsTab/HostDetailsTab.tsx b/webpack/components/extensions/host_details/HostDetailsTab/HostDetailsTab.tsx index 3db1ed04..ce040a4d 100644 --- a/webpack/components/extensions/host_details/HostDetailsTab/HostDetailsTab.tsx +++ b/webpack/components/extensions/host_details/HostDetailsTab/HostDetailsTab.tsx @@ -4,24 +4,12 @@ import { EmptyStateHeader, EmptyStateIcon, Spinner, - Tab, - Tabs, - TabTitleIcon, - TabTitleText, } from '@patternfly/react-core'; -import IntegrationIcon from '@patternfly/react-icons/dist/esm/icons/integration-icon'; -import DatabaseIcon from '@patternfly/react-icons/dist/esm/icons/database-icon'; - import { UseAPIReturn } from 'foremanReact/common/hooks/API/APIHooks'; -import Permitted from 'foremanReact/components/Permitted'; import { translate as _ } from 'foremanReact/common/I18n'; -import { AssignmentComponentWrapper } from './components/AssignmentComponentWrapper'; -import { OverrideGridWrapper } from './components/OverrideGridWrapper'; -import { AdPermissions } from '../../../../constants/foremanAnsibleDirectorPermissions'; - -// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +import { AnsibleContentAssignmentWrapper } from '../../../common/AnsibleContentAssignment/AnsibleContentAssignmentWrapper'; interface HostDetailsTabProps extends UseAPIReturn<{ @@ -35,56 +23,14 @@ export const HostDetailsTab = ({ status, response, }: HostDetailsTabProps): ReactElement => { - const [activeTabKey, setActiveTabKey] = React.useState(0); - if (status === 'RESOLVED') { return ( - setActiveTabKey(tabIndex)} - isBox - aria-label="Tabs in the filled with icons example" - role="region" - > - - - - {' '} - {_('Content')}{' '} - - } - > - - - - - - - - {' '} - {_('Variables')}{' '} - - } - > - - - - - + ); } return ( diff --git a/webpack/helpers/comparisons.ts b/webpack/helpers/comparisons.ts new file mode 100644 index 00000000..aa12cb2c --- /dev/null +++ b/webpack/helpers/comparisons.ts @@ -0,0 +1,6 @@ +import { ContentResolutionNode } from '../types/AnsibleContentAssignmentTypes'; + +export const equalsCrn = ( + a: ContentResolutionNode, + b: ContentResolutionNode +): boolean => a.type === b.type && a.id === b.id; diff --git a/webpack/helpers/typeGuards/contentAssignmentTypeGuards.ts b/webpack/helpers/typeGuards/contentAssignmentTypeGuards.ts new file mode 100644 index 00000000..b2d0aa42 --- /dev/null +++ b/webpack/helpers/typeGuards/contentAssignmentTypeGuards.ts @@ -0,0 +1,22 @@ +import { + AnsibleCollectionRoleAssignment, + AnsibleContentAssignment, + AnsibleRoleAssignment, + AssignableBase, + ResolvedAssignment, +} from '../../types/AnsibleContentAssignmentTypes'; + +export const isCollectionRoleAssignment = ( + assignment: AssignableBase +): assignment is AnsibleCollectionRoleAssignment => + 'assignable_role_name' in assignment; + +export const isResolvedCollectionRoleAssignment = ( + resolvedAssignment: ResolvedAssignment +): resolvedAssignment is ResolvedAssignment => + 'assignable_role_name' in resolvedAssignment; + +export const isRoleAssignment = ( + assignment: AssignableBase +): assignment is AnsibleRoleAssignment => + !isCollectionRoleAssignment(assignment); diff --git a/webpack/types/AnsibleContentAssignmentTypes.d.ts b/webpack/types/AnsibleContentAssignmentTypes.d.ts new file mode 100644 index 00000000..b81b7ce8 --- /dev/null +++ b/webpack/types/AnsibleContentAssignmentTypes.d.ts @@ -0,0 +1,65 @@ +import { Identifiable } from './AnsibleExecutionEnvTypes'; + +export interface AnsibleContentAssignmentHierarchy {} + +export interface AssignableBase extends Identifiable { + // eslint-disable-next-line camelcase + assignable_name: string; + // eslint-disable-next-line camelcase + assignable_namespace: string; + // eslint-disable-next-line camelcase + consumable_id: number; + // eslint-disable-next-line camelcase + consumable_type: ContentResolutionNodeType; + // eslint-disable-next-line camelcase + subtractive: boolean; + resolved: null; +} + +export type AnsibleContentAssignment = + | AnsibleRoleAssignment + | AnsibleCollectionRoleAssignment; + +export type ResolvedAssignment = Omit< + T, + 'resolved' +> & { + resolved: AssignmentResolution; +}; + +export type Fqrnable = + | AnsibleContentAssignment + | AnsibleContentAssignmentCreate + | ResolvedAssignment; + +export interface AnsibleRoleAssignment extends AssignableBase { + // eslint-disable-next-line camelcase + assignable_type: 'ForemanAnsibleDirector::AnsibleRole'; +} +export interface AnsibleCollectionRoleAssignment extends AssignableBase { + // eslint-disable-next-line camelcase + assignable_type: 'ForemanAnsibleDirector::AnsibleCollectionRole'; + // eslint-disable-next-line camelcase + assignable_role_name: string; +} + +type ContentResolutionNodeType = 'Host' | 'Hostgroup'; + +export interface ContentResolutionNode extends Identifiable { + type: ContentResolutionNodeType; + name: string; +} + +export type CrnHierarchy = readonly [ + ContentResolutionNode, + ContentResolutionNode?, + ContentResolutionNode? +]; + +export type AnsibleContentAssignmentCreate< + T extends AnsibleContentAssignment +> = Omit; + +export interface AssignmentResolution extends Identifiable { + version: string; +} diff --git a/webpack/types/AnsibleContentTypes.d.ts b/webpack/types/AnsibleContentTypes.d.ts index 31ec2d7e..90287a0c 100644 --- a/webpack/types/AnsibleContentTypes.d.ts +++ b/webpack/types/AnsibleContentTypes.d.ts @@ -53,6 +53,8 @@ export interface AnsibleContentVersionFull roles: AnsibleCollectionRole[]; } +// TODO: Split this into Role and Collection types. +// As is, the UI has to rely on the fact that the roles key only exists for Collection roles. export interface AnsibleContentUnitAssignment extends Identifiable { type: 'collection' | 'role'; identifier: string; diff --git a/webpack/types/common.d.ts b/webpack/types/common.d.ts new file mode 100644 index 00000000..cf8f5400 --- /dev/null +++ b/webpack/types/common.d.ts @@ -0,0 +1,28 @@ +export type pfAlertVariant = + | 'success' + | 'danger' + | 'warning' + | 'info' + | 'custom' + | undefined; + +export type pfLabelColorType = + | 'blue' + | 'cyan' + | 'green' + | 'orange' + | 'purple' + | 'red' + | 'grey' + | 'gold'; + +export interface DefaultResponse< + TError extends AnsibleDirectorError, + TWarning extends AnsibleDirectorWarning, + TResponse +> { + status: 'error' | 'success'; + errors: TError[]; + warnings: TWarning[]; + results: TResponse; +} diff --git a/webpack/types/issues/errors.d.ts b/webpack/types/issues/errors.d.ts new file mode 100644 index 00000000..ae36f1c1 --- /dev/null +++ b/webpack/types/issues/errors.d.ts @@ -0,0 +1,4 @@ +export interface AnsibleDirectorError { + title: string; + message: string; +} diff --git a/webpack/types/issues/warnings.d.ts b/webpack/types/issues/warnings.d.ts new file mode 100644 index 00000000..58ac8c58 --- /dev/null +++ b/webpack/types/issues/warnings.d.ts @@ -0,0 +1,9 @@ +export interface AnsibleDirectorWarning { + title: string; + message: string; +} + +export interface ResolutionWarning extends AnsibleDirectorWarning { + // eslint-disable-next-line camelcase + assignment_id: number; +}