Direct Routed networks: let the UI pass an operator-chosen routed id

CreateL3NetworkForm only supported offerings that allocate the routed id
automatically; an operator-chosen id (the API's vlan parameter, like a
Shared network's VLAN tag) could be given through the API alone. Add a
'Routed ID' field to the form, shown to root admins when the selected
L3 offering carries specifyVlan - where the API requires the id - and
hidden otherwise, since the API then rejects the parameter and
allocates the id from the ROUTED physical network's range.

The field's description explains what the id is: a label naming the
per-network bridge (brdr-<id>) on every KVM host, unique per zone,
nothing on the wire, and useful to choose in advance so host routing
policy can exist before the network does.
diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json
index 78cf906..e1715fc 100644
--- a/ui/public/locales/en.json
+++ b/ui/public/locales/en.json
@@ -2304,6 +2304,7 @@
 "label.rootdisksize": "Root disk size (GB)",
 "label.routenexthop": "Route next hop",
 "label.routenexthoptype": "Route next hop type",
+"label.routedid": "Routed ID",
 "label.routeprefix": "Route prefix",
 "label.router.health.check.last.updated": "Last updated",
 "label.router.health.check.name": "Check name",
@@ -3691,6 +3692,7 @@
 "message.error.required.input": "Please enter input",
 "message.error.reset.config": "Unable to reset config to default value",
 "message.error.retrieve.kubeconfig": "Unable to retrieve Kubernetes Cluster config",
+"message.error.routedid": "Please enter the Routed ID for this network; the selected network offering requires the operator to choose it",
 "message.error.routing.policy.term": "Community need to have the following format number:number",
 "message.error.s3nfs.path": "Please enter S3 NFS Path",
 "message.error.s3nfs.server": "Please enter S3 NFS Server",
@@ -3952,6 +3954,7 @@
 "message.releasing.dedicated.zone": "Releasing dedicated Zone...",
 "message.remote.access.vpn.iprange.description": "The range of IP addresses to allocate to VPN clients. The first IP in the range will be taken by the VPN server. (Optional)",
 "message.remove.annotation": "Are you sure you want to delete the comment?",
+"message.routedid.description": "Numeric ID for this Direct Routed (L3) network, chosen by the operator. The ID names the network's bridge on every KVM host (brdr-<ID>) and must be unique within the zone; it is only a label, nothing appears on the wire. Choosing it yourself makes the bridge name plannable, so host routing policy can be prepared before the network exists. Network offerings without 'specify VLAN' allocate the ID automatically from the ROUTED physical network's range instead.",
 "message.remove.egress.rule.failed": "Removing egress rule failed",
 "message.remove.egress.rule.processing": "Deleting egress rule...",
 "message.remove.failed": "Removing failed",
diff --git a/ui/src/views/network/CreateL3NetworkForm.vue b/ui/src/views/network/CreateL3NetworkForm.vue
index b6a04e8..7e2fd2a 100644
--- a/ui/src/views/network/CreateL3NetworkForm.vue
+++ b/ui/src/views/network/CreateL3NetworkForm.vue
@@ -92,6 +92,17 @@
               </a-select-option>
             </a-select>
           </a-form-item>
+          <a-form-item
+            v-if="!isObjectEmpty(selectedNetworkOffering) && selectedNetworkOffering.specifyvlan && isAdmin()"
+            name="routedid"
+            ref="routedid">
+            <template #label>
+              <tooltip-label :title="$t('label.routedid')" :tooltip="$t('message.routedid.description')"/>
+            </template>
+            <a-input
+              v-model:value="form.routedid"
+              :placeholder="$t('message.routedid.description')"/>
+          </a-form-item>
           <a-row :gutter="12">
             <a-col :md="12" :lg="12">
               <a-form-item name="gateway" ref="gateway">
@@ -223,7 +234,7 @@
 <script>
 import { ref, reactive, toRaw } from 'vue'
 import { getAPI, postAPI } from '@/api'
-import { isAdminOrDomainAdmin } from '@/role'
+import { isAdmin, isAdminOrDomainAdmin } from '@/role'
 import { mixinForm } from '@/utils/mixin'
 import ResourceIcon from '@/components/view/ResourceIcon'
 import TooltipLabel from '@/components/widgets/TooltipLabel'
@@ -275,6 +286,7 @@
     this.fetchData()
   },
   methods: {
+    isAdmin,
     isAdminOrDomainAdmin,
     initForm () {
       this.formRef = ref()
@@ -282,7 +294,10 @@
       this.rules = reactive({
         name: [{ required: true, message: this.$t('message.error.name') }],
         zoneid: [{ required: true, message: this.$t('message.error.select') }],
-        networkofferingid: [{ required: true, message: this.$t('message.error.select') }]
+        networkofferingid: [{ required: true, message: this.$t('message.error.select') }],
+        // Only rendered (and thus validated) for offerings with specifyVlan, where the API
+        // requires the operator to pick the routed id.
+        routedid: [{ required: true, message: this.$t('message.error.routedid') }]
       })
     },
     fetchData () {
@@ -356,6 +371,12 @@
             params[key] = values[key]
           }
         }
+        // The operator-chosen routed id travels in the API's vlan parameter, like the VLAN
+        // tag of a Shared network. Only valid with a specifyVlan network offering; otherwise
+        // CloudStack allocates the id from the ROUTED physical network's range.
+        if (this.selectedNetworkOffering.specifyvlan && values.routedid) {
+          params.vlan = values.routedid
+        }
         if (this.owner.account) {
           params.account = this.owner.account
           params.domainid = this.owner.domainid