From 059f4e56a4e562b62fdcd15cbb23034dcc9a2071 Mon Sep 17 00:00:00 2001 From: hgw77 Date: Tue, 18 Aug 2026 14:27:37 +0200 Subject: [PATCH 1/8] feat(network): add flavors to new router --- .../networking/routers_controller.rb | 20 ++++++++++-- .../app/javascript/plugin/router.js | 28 +++++++++++++++++ .../app/models/networking/flavor.rb | 7 +++++ .../app/models/networking/router.rb | 7 ++++- .../service_layer/networking_service.rb | 1 + .../networking_services/flavor.rb | 31 +++++++++++++++++++ .../views/networking/routers/new.html.haml | 12 +++++++ 7 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 plugins/networking/app/models/networking/flavor.rb create mode 100644 plugins/networking/app/services/service_layer/networking_services/flavor.rb diff --git a/plugins/networking/app/controllers/networking/routers_controller.rb b/plugins/networking/app/controllers/networking/routers_controller.rb index c206ef2abb..3a4e99435c 100644 --- a/plugins/networking/app/controllers/networking/routers_controller.rb +++ b/plugins/networking/app/controllers/networking/routers_controller.rb @@ -140,12 +140,18 @@ def show def new # build new router object (no api call done yet!) @router = services.networking.new_router("admin_state_up" => true) + @flavors = services.networking.flavors(service_type: "L3_ROUTER_NAT") end def create if params["router"]["external_gateway_info"]["network_id"].blank? params["router"].delete("external_gateway_info") end + # remove blank flavor_id so it's not sent to the API + flavor_id = params["router"].delete("flavor_id") + params["router"]["flavor_id"] = flavor_id if flavor_id.present? + # keep availability_zone_hints as string in model (for form re-render), convert to array before API call + az = (params["router"]["availability_zone_hints"] || "").strip # get selected subnets and remove them from params @selected_internal_subnets = (params[:router].delete(:internal_subnets) || []).reject(&:empty?) @@ -153,7 +159,17 @@ def create @router = services.networking.new_router(params[:router]) @router.internal_subnets = @selected_internal_subnets - if @router.save + # pass flavor name to model so it can validate AZ requirement for VPNaaS + if flavor_id.present? + @flavors = services.networking.flavors(service_type: "L3_ROUTER_NAT") + selected_flavor = @flavors.find { |f| f.id == flavor_id } + @router.flavor_name = selected_flavor&.name.to_s + end + + if @router.valid? + # convert AZ string to array before saving to API + @router.write("availability_zone_hints", az.blank? ? [] : [az]) + @router.save # router is created -> add subnets as interfaces services.networking.add_router_interfaces( @router.id, @@ -164,7 +180,7 @@ def create flash.now[:notice] = "Router successfully created." redirect_to plugin("networking").routers_path else - # didn't save -> render new + @flavors ||= services.networking.flavors(service_type: "L3_ROUTER_NAT") render action: :new end end diff --git a/plugins/networking/app/javascript/plugin/router.js b/plugins/networking/app/javascript/plugin/router.js index cb1efa74dc..0cca6139fc 100644 --- a/plugins/networking/app/javascript/plugin/router.js +++ b/plugins/networking/app/javascript/plugin/router.js @@ -74,6 +74,34 @@ const init = function () { if ($("#router_external_gateway_info_network_id").val()) { loadSubnets($("#router_external_gateway_info_network_id").val()) } + + handleFlavorChange() } $(document).on("modal:contentUpdated", (e) => init()) + +const handleFlavorChange = function () { + const $flavorSelect = $("#router_flavor_id") + if ($flavorSelect.length === 0) return + + const $azHint = $("#availability_zone_group .col-sm-8 > p.help-block") + const $azLabel = $("#availability_zone_group label") + const requiredMarker = '* ' + + const update = function () { + const isVpnaas = $flavorSelect.find("option:selected").text().toLowerCase().includes("vpnaas") + $azHint.toggle(isVpnaas) + if (isVpnaas) { + if ($("#az_required_marker").length === 0) { + $azLabel.prepend(requiredMarker) + $azLabel.addClass("required") + } + } else { + $("#az_required_marker").remove() + $azLabel.removeClass("required") + } + } + + $flavorSelect.on("change", update) + update() +} diff --git a/plugins/networking/app/models/networking/flavor.rb b/plugins/networking/app/models/networking/flavor.rb new file mode 100644 index 0000000000..f7cdf1d828 --- /dev/null +++ b/plugins/networking/app/models/networking/flavor.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Networking + # Represents a Neutron Network Flavor + class Flavor < Core::ServiceLayer::Model + end +end diff --git a/plugins/networking/app/models/networking/router.rb b/plugins/networking/app/models/networking/router.rb index d805147203..c4e09ba6cf 100644 --- a/plugins/networking/app/models/networking/router.rb +++ b/plugins/networking/app/models/networking/router.rb @@ -4,8 +4,13 @@ module Networking # represents the Openstack Router class Router < Core::ServiceLayer::Model validates :name, presence: { message: "Please provide a name" } + validates :availability_zone_hints, presence: { message: "is required for VPNaaS routers" }, if: :vpnaas_flavor? - attr_accessor :internal_subnets + attr_accessor :internal_subnets, :flavor_name + + def vpnaas_flavor? + flavor_name.to_s.downcase.include?("vpnaas") + end def ip_subnet_objects return @ip_subnet_objects if @ip_subnet_objects diff --git a/plugins/networking/app/services/service_layer/networking_service.rb b/plugins/networking/app/services/service_layer/networking_service.rb index f19b153c15..a80c542f3c 100644 --- a/plugins/networking/app/services/service_layer/networking_service.rb +++ b/plugins/networking/app/services/service_layer/networking_service.rb @@ -15,6 +15,7 @@ class NetworkingService < Core::ServiceLayer::Service include NetworkingServices::DhcpAgent include NetworkingServices::Asr include NetworkingServices::BgpVpn + include NetworkingServices::Flavor def available?(_action_name_sym = nil) elektron.service?("network") diff --git a/plugins/networking/app/services/service_layer/networking_services/flavor.rb b/plugins/networking/app/services/service_layer/networking_services/flavor.rb new file mode 100644 index 0000000000..f8c519584b --- /dev/null +++ b/plugins/networking/app/services/service_layer/networking_services/flavor.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module ServiceLayer + module NetworkingServices + # Implements Neutron Network Flavors API + module Flavor + def flavor_map + @flavor_map ||= class_map_proc(Networking::Flavor) + end + + def flavors(filter = {}) + elektron_networking.get("flavors", filter).map_to( + "body.flavors", + &flavor_map + ) + rescue Elektron::Errors::ApiResponse + [] + end + + def find_flavor(id) + return nil unless id + elektron_networking.get("flavors/#{id}").map_to( + "body.flavor", + &flavor_map + ) + rescue Elektron::Errors::ApiResponse + nil + end + end + end +end diff --git a/plugins/networking/app/views/networking/routers/new.html.haml b/plugins/networking/app/views/networking/routers/new.html.haml index 8fb3ba3539..4ed68e2b0a 100644 --- a/plugins/networking/app/views/networking/routers/new.html.haml +++ b/plugins/networking/app/views/networking/routers/new.html.haml @@ -10,6 +10,18 @@ as: :select, collection: [['UP', 'true'],['DOWN', 'false']]} + - if @flavors.present? + - flavor_options = @flavors.select(&:enabled).map { |fl| [fl.description.present? ? "#{fl.name} – #{fl.description}" : fl.name, fl.id] } + = f.input :flavor_id, label: 'Network Flavor', + as: :select, + include_blank: 'None (standard router)', + collection: flavor_options, + required: false, + input_html: { id: 'router_flavor_id' }, + icon_hint: "The flavor cannot be changed after the router is created." + + = f.input :availability_zone_hints, label: 'Availability Zone', as: :string, required: false, placeholder: 'e.g. qa-de-1a', input_html: { id: 'router_availability_zone_hints' }, icon_hint: "VPNaaS routers require an availability zone. For redundancy, consider creating a second VPN router in a different availability zone.", wrapper_html: { id: 'availability_zone_group' } + = f.simple_fields_for :external_gateway_info do |info| = info.input :network_id, {label: "Floating IP Network", From 26ee5fd06847926622cd1c180792ae5735c8fcfc Mon Sep 17 00:00:00 2001 From: hgw77 Date: Tue, 18 Aug 2026 14:31:34 +0200 Subject: [PATCH 2/8] feat(network): show network flavors on router object --- .../app/controllers/networking/routers_controller.rb | 1 + .../views/networking/routers/_item_details.html.haml | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/plugins/networking/app/controllers/networking/routers_controller.rb b/plugins/networking/app/controllers/networking/routers_controller.rb index 3a4e99435c..7aac1667e1 100644 --- a/plugins/networking/app/controllers/networking/routers_controller.rb +++ b/plugins/networking/app/controllers/networking/routers_controller.rb @@ -135,6 +135,7 @@ def show device_id: @router.id, device_owner: "network:router_interface", ) + @router_flavor = services.networking.find_flavor(@router.flavor_id) if @router.flavor_id.present? end def new diff --git a/plugins/networking/app/views/networking/routers/_item_details.html.haml b/plugins/networking/app/views/networking/routers/_item_details.html.haml index 0ac81bab29..5b078cee2f 100644 --- a/plugins/networking/app/views/networking/routers/_item_details.html.haml +++ b/plugins/networking/app/views/networking/routers/_item_details.html.haml @@ -29,6 +29,18 @@ %tr %th Admin State %td= @router.admin_state_up ? 'UP' : 'DOWN' + %tr + %th Network Flavor + %td + - if @router_flavor + = @router_flavor.name + - if @router_flavor.description.present? + \– + = @router_flavor.description + %br + %small.text-muted= @router_flavor.id + - else + None %tr %th Hosting Device %td= @router.hosting_device From a7be91344a220a97333b01ee9e5cdbb9db5f18f0 Mon Sep 17 00:00:00 2001 From: hgw77 Date: Tue, 18 Aug 2026 14:47:58 +0200 Subject: [PATCH 3/8] feat(network): edit routers with vpanss do not allow to add private networks --- .../networking/routers_controller.rb | 3 +- .../app/javascript/plugin/router.js | 3 ++ .../views/networking/routers/edit.html.haml | 31 ++++++++++++++----- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/plugins/networking/app/controllers/networking/routers_controller.rb b/plugins/networking/app/controllers/networking/routers_controller.rb index 7aac1667e1..1eef22b73a 100644 --- a/plugins/networking/app/controllers/networking/routers_controller.rb +++ b/plugins/networking/app/controllers/networking/routers_controller.rb @@ -212,6 +212,7 @@ def edit data["subnet_id"] end end + @router_flavor = services.networking.find_flavor(@router.flavor_id) if @router.flavor_id.present? end def update @@ -219,7 +220,7 @@ def update params[:router].delete(:action_from_show) == "true" || false # get selected subnets and remove them from params @selected_internal_subnet_ids = - (params[:router].delete(:internal_subnets) || []).reject(&:empty?) + Array(params[:router].delete(:internal_subnets)).reject(&:empty?) # build new router object @router = services.networking.new_router(params[:router].to_unsafe_hash) diff --git a/plugins/networking/app/javascript/plugin/router.js b/plugins/networking/app/javascript/plugin/router.js index 0cca6139fc..0d81c0ee8a 100644 --- a/plugins/networking/app/javascript/plugin/router.js +++ b/plugins/networking/app/javascript/plugin/router.js @@ -86,6 +86,7 @@ const handleFlavorChange = function () { const $azHint = $("#availability_zone_group .col-sm-8 > p.help-block") const $azLabel = $("#availability_zone_group label") + const $internalSubnets = $("#router_internal_subnets").closest(".form-group") const requiredMarker = '* ' const update = function () { @@ -96,9 +97,11 @@ const handleFlavorChange = function () { $azLabel.prepend(requiredMarker) $azLabel.addClass("required") } + $internalSubnets.hide() } else { $("#az_required_marker").remove() $azLabel.removeClass("required") + $internalSubnets.show() } } diff --git a/plugins/networking/app/views/networking/routers/edit.html.haml b/plugins/networking/app/views/networking/routers/edit.html.haml index 84fa094ac5..36a7233503 100644 --- a/plugins/networking/app/views/networking/routers/edit.html.haml +++ b/plugins/networking/app/views/networking/routers/edit.html.haml @@ -5,6 +5,10 @@ %p.alert.alert-error = @router.errors.full_messages.to_sentence + '.' + - if @router_flavor&.name.to_s.include?("vpnaas") + %p.alert.alert-info + VPNaaS routers cannot have internal networks attached. Use BGPVPN for connectivity. + = f.input :action_from_show, :as => :hidden, :input_html => { :value => @action_from_show } = f.input :name @@ -25,13 +29,26 @@ collection: @subnets, selected: @router_external_subnet_ids - = f.input :internal_subnets, - label: "Private Network Subnets", - wrapper: :horizontal_radio_and_checkboxes_4x8_scrollable, - required: true, - as: :check_boxes, - collection: @internal_subnets.sort{|a,b| a.network_name<=>b.network_name}.map{|n| ["#{n.name} (#{n.network_name})",n.id]}, - checked: @router_internal_subnet_ids + - if @router_flavor&.name.to_s.include?("vpnaas") + .form-group + %label.col-sm-4.control-label Network Flavor + .col-sm-8 + %p.form-control-static + = @router_flavor.name + - if @router_flavor.description.present? + \– + = @router_flavor.description + %br + %small.text-muted= @router_flavor.id + = f.input :internal_subnets, as: :hidden + - else + = f.input :internal_subnets, + label: "Private Network Subnets", + wrapper: :horizontal_radio_and_checkboxes_4x8_scrollable, + required: true, + as: :check_boxes, + collection: @internal_subnets.sort{|a,b| a.network_name<=>b.network_name}.map{|n| ["#{n.name} (#{n.network_name})",n.id]}, + checked: @router_internal_subnet_ids From 6cd170184d514e6c69f77cf620966a38e9c34d09 Mon Sep 17 00:00:00 2001 From: hgw77 Date: Tue, 18 Aug 2026 14:55:45 +0200 Subject: [PATCH 4/8] feat(network): add list to show flavors and adjust description --- .../app/controllers/networking/routers_controller.rb | 1 + .../networking/app/views/networking/routers/index.html.haml | 5 +++++ .../networking/app/views/networking/routers/new.html.haml | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/plugins/networking/app/controllers/networking/routers_controller.rb b/plugins/networking/app/controllers/networking/routers_controller.rb index 1eef22b73a..035590ef3d 100644 --- a/plugins/networking/app/controllers/networking/routers_controller.rb +++ b/plugins/networking/app/controllers/networking/routers_controller.rb @@ -8,6 +8,7 @@ class RoutersController < DashboardController def index ################# NEW @routers = [] + @flavors_by_id = services.networking.flavors.index_by(&:id) if current_user.is_allowed?("context_is_cloud_network_admin") @routers = diff --git a/plugins/networking/app/views/networking/routers/index.html.haml b/plugins/networking/app/views/networking/routers/index.html.haml index d38482b13d..f41c3403a0 100644 --- a/plugins/networking/app/views/networking/routers/index.html.haml +++ b/plugins/networking/app/views/networking/routers/index.html.haml @@ -14,6 +14,7 @@ %th External Network %th External Subnet %th Private Network + %th Flavor %th Status %th.snug %tbody @@ -49,6 +50,10 @@ %br %span.info-text= net.id + %td + - flavor = @flavors_by_id[router.flavor_id] + - if flavor + %span.label.label-default= flavor.name %td= router.status %td.snug = render partial: 'item_actions', locals: {router:router, show_view:false} \ No newline at end of file diff --git a/plugins/networking/app/views/networking/routers/new.html.haml b/plugins/networking/app/views/networking/routers/new.html.haml index 4ed68e2b0a..d2b9d8c905 100644 --- a/plugins/networking/app/views/networking/routers/new.html.haml +++ b/plugins/networking/app/views/networking/routers/new.html.haml @@ -11,7 +11,7 @@ collection: [['UP', 'true'],['DOWN', 'false']]} - if @flavors.present? - - flavor_options = @flavors.select(&:enabled).map { |fl| [fl.description.present? ? "#{fl.name} – #{fl.description}" : fl.name, fl.id] } + - flavor_options = @flavors.select(&:enabled).map { |fl| [fl.description.present? ? "#{fl.name} (#{fl.description})" : fl.name, fl.id] } = f.input :flavor_id, label: 'Network Flavor', as: :select, include_blank: 'None (standard router)', From 206377c22a1c4dd9e55fc07538314ef42c5122ab Mon Sep 17 00:00:00 2001 From: hgw77 Date: Tue, 18 Aug 2026 15:02:53 +0200 Subject: [PATCH 5/8] feat(network): badges for router status in list --- .../app/views/networking/routers/_item_details.html.haml | 6 +++--- .../app/views/networking/routers/index.html.haml | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/plugins/networking/app/views/networking/routers/_item_details.html.haml b/plugins/networking/app/views/networking/routers/_item_details.html.haml index 5b078cee2f..899deb661d 100644 --- a/plugins/networking/app/views/networking/routers/_item_details.html.haml +++ b/plugins/networking/app/views/networking/routers/_item_details.html.haml @@ -34,10 +34,10 @@ %td - if @router_flavor = @router_flavor.name - - if @router_flavor.description.present? - \– - = @router_flavor.description %br + - if @router_flavor.description.present? + %small.text-muted= @router_flavor.description + %br %small.text-muted= @router_flavor.id - else None diff --git a/plugins/networking/app/views/networking/routers/index.html.haml b/plugins/networking/app/views/networking/routers/index.html.haml index f41c3403a0..dec892e0fd 100644 --- a/plugins/networking/app/views/networking/routers/index.html.haml +++ b/plugins/networking/app/views/networking/routers/index.html.haml @@ -54,6 +54,12 @@ - flavor = @flavors_by_id[router.flavor_id] - if flavor %span.label.label-default= flavor.name - %td= router.status + %td + - status_class = case router.status + - when 'ACTIVE' then 'label-success' + - when 'ERROR' then 'label-danger' + - when 'DOWN' then 'label-default' + - else 'label-warning' + %span.label{class: status_class}= router.status %td.snug = render partial: 'item_actions', locals: {router:router, show_view:false} \ No newline at end of file From b4323ade7ab3ac1568dbdee473f5a20f9ea8e278 Mon Sep 17 00:00:00 2001 From: hgw77 Date: Tue, 18 Aug 2026 15:05:21 +0200 Subject: [PATCH 6/8] feat(network): add title with description for flavor --- plugins/networking/app/views/networking/routers/index.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/networking/app/views/networking/routers/index.html.haml b/plugins/networking/app/views/networking/routers/index.html.haml index dec892e0fd..8895ffc493 100644 --- a/plugins/networking/app/views/networking/routers/index.html.haml +++ b/plugins/networking/app/views/networking/routers/index.html.haml @@ -53,7 +53,7 @@ %td - flavor = @flavors_by_id[router.flavor_id] - if flavor - %span.label.label-default= flavor.name + %span.label.label-default{title: flavor.description}= flavor.name %td - status_class = case router.status - when 'ACTIVE' then 'label-success' From e68558dfa887db1955af87339c7cf74e36583635 Mon Sep 17 00:00:00 2001 From: hgw77 Date: Wed, 19 Aug 2026 13:58:13 +0200 Subject: [PATCH 7/8] feat(network): add immutable warning for selected flavor in router creation --- plugins/networking/app/javascript/plugin/router.js | 3 +++ .../app/views/networking/routers/new.html.haml | 13 ++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/networking/app/javascript/plugin/router.js b/plugins/networking/app/javascript/plugin/router.js index 0d81c0ee8a..2bff2023c4 100644 --- a/plugins/networking/app/javascript/plugin/router.js +++ b/plugins/networking/app/javascript/plugin/router.js @@ -87,11 +87,14 @@ const handleFlavorChange = function () { const $azHint = $("#availability_zone_group .col-sm-8 > p.help-block") const $azLabel = $("#availability_zone_group label") const $internalSubnets = $("#router_internal_subnets").closest(".form-group") + const $immutableWarning = $("#flavor_immutable_warning") const requiredMarker = '* ' const update = function () { const isVpnaas = $flavorSelect.find("option:selected").text().toLowerCase().includes("vpnaas") + const hasFlavorSelected = $flavorSelect.val() !== "" $azHint.toggle(isVpnaas) + $immutableWarning.toggle(hasFlavorSelected) if (isVpnaas) { if ($("#az_required_marker").length === 0) { $azLabel.prepend(requiredMarker) diff --git a/plugins/networking/app/views/networking/routers/new.html.haml b/plugins/networking/app/views/networking/routers/new.html.haml index d2b9d8c905..95ba72d4e7 100644 --- a/plugins/networking/app/views/networking/routers/new.html.haml +++ b/plugins/networking/app/views/networking/routers/new.html.haml @@ -5,6 +5,9 @@ %p.alert.alert-error = @router.errors.full_messages.to_sentence + '.' + %p.alert.alert-warning#flavor_immutable_warning{style: 'display:none'} + The selected flavor cannot be changed after the router is created. + = f.input :name = f.input :admin_state_up, {label: 'Admin State', as: :select, @@ -12,11 +15,11 @@ - if @flavors.present? - flavor_options = @flavors.select(&:enabled).map { |fl| [fl.description.present? ? "#{fl.name} (#{fl.description})" : fl.name, fl.id] } - = f.input :flavor_id, label: 'Network Flavor', - as: :select, - include_blank: 'None (standard router)', - collection: flavor_options, - required: false, + = f.input :flavor_id, label: 'Network Flavor', + as: :select, + include_blank: 'None (standard router)', + collection: flavor_options, + required: false, input_html: { id: 'router_flavor_id' }, icon_hint: "The flavor cannot be changed after the router is created." From cbfd10a79961819ba80c9533898e873de34d6875 Mon Sep 17 00:00:00 2001 From: Hans-Georg Winkler Date: Mon, 24 Aug 2026 13:30:07 +0200 Subject: [PATCH 8/8] Update index.html.haml --- .../networking/app/views/networking/routers/index.html.haml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/networking/app/views/networking/routers/index.html.haml b/plugins/networking/app/views/networking/routers/index.html.haml index 8895ffc493..6af26954d0 100644 --- a/plugins/networking/app/views/networking/routers/index.html.haml +++ b/plugins/networking/app/views/networking/routers/index.html.haml @@ -55,6 +55,7 @@ - if flavor %span.label.label-default{title: flavor.description}= flavor.name %td + - status_class = case router.status - when 'ACTIVE' then 'label-success' - when 'ERROR' then 'label-danger' @@ -62,4 +63,4 @@ - else 'label-warning' %span.label{class: status_class}= router.status %td.snug - = render partial: 'item_actions', locals: {router:router, show_view:false} \ No newline at end of file + = render partial: 'item_actions', locals: {router:router, show_view:false}