feat(xo6/traffic rule): edit a traffic rule (#10056)

This commit is contained in:
Marie Garde
2026-09-01 11:22:03 +02:00
committed by GitHub
parent a2ce3bbc48
commit a67dd73304
25 changed files with 615 additions and 30 deletions

View File

@@ -755,9 +755,9 @@ export type TrafficRuleDirection = 'from' | 'to' | 'from/to'
export type RawTrafficRule = {
allow: boolean
protocol: string
protocol: TrafficRuleProtocol
ipRange: string
direction: string
direction: TrafficRuleDirection
port?: string
}

View File

@@ -153,3 +153,10 @@ If the translation in your language for this new key is the same as an already e
```
This provides the option to override the `save-changes` translation in some locales if needed, while keeping the same translation as `save` by default.
Some translations use modifiers on linked translations, such as `@.lower:<key>` (lowercase) or `@.capitalize:<key>`.
These modifiers automatically transform the casing of the referenced translation.
> [!IMPORTANT]
> When translating a key that is referenced with a modifier, keep in mind that the output will be automatically transformed. Make sure the result is correct in your language, and override the referencing key with a direct translation if needed.

View File

@@ -295,3 +295,21 @@ If you really need to **exceptionally** include a literal string, you can either
For other locales added by contributors, some keys might be missing when we add new translations.
This can result in ESLint errors when some translation keys are missing. To avoid this, remember to add the locale to the list of excluded files in `.eslintrc.js` at the root of the project (rule is `'@intlify/vue-i18n/no-missing-keys-in-other-locales': ['error', { ignoreLocales: ['de', 'fa'] }]`), and update the `@xen-orchestra/web-core/lib/i18n.ts` file to include the new locale.
### Linked translations with modifiers
Vue-i18n provides built-in modifiers that can be applied to linked translations, such as @.lower:<key>, @.upper:<key>, or @.capitalize:<key>.
These modifiers transform the referenced translation (e.g., forcing it to lowercase).
> \[!IMPORTANT\]
> Case transformation rules vary across languages (e.g., German capitalizes all nouns, Turkish has specific `I`/`ı` casing rules). When using a modifier, the referenced key SHOULD contain a simple, standalone word - not a sentence or phrase.
#### Example
```json
{
"vif": "VIF",
"on-vif": "on {'@.lower:vif'}"
}
```

View File

@@ -71,6 +71,7 @@
"action:duplicate-n-vms": "Duplicate 1 VM | Duplicate {n} VMs",
"action:edit": "Edit",
"action:edit-config": "Edit config",
"action:edit-traffic-rule": "@:action:edit @.lower:traffic-rule",
"action:enable": "Enable",
"action:enable-host": "Enable host",
"action:enable-light-mode": "Enable light mode",
@@ -391,6 +392,7 @@
"fast-clone-available": "Fast clone is available only for halted VMs.",
"fetching-fresh-data": "Fetching fresh data",
"field:exceeds-max-characters": "Field value must be {max} characters or less.",
"field:required": "Field is required.",
"file": "File",
"filter-actions": "Filter actions",
"following-hosts-unreachable": "The following hosts are unreachable",
@@ -505,6 +507,7 @@
"job:delete:in-progress": "Delete in progress…",
"job:disable:in-progress": "Disable in progress…",
"job:disconnect:in-progress": "Disconnect in progress…",
"job:edit:in-progress": "Edit in progress…",
"job:enable:in-progress": "Enable in progress…",
"job:export:in-progress": "Export in progress…",
"job:host-detach:in-progress": "Host detach in progress…",

View File

@@ -71,6 +71,7 @@
"action:duplicate-n-vms": "Dupliquer 1 VM | Dupliquer {n} VMs",
"action:edit": "Modifier",
"action:edit-config": "Modifier config",
"action:edit-traffic-rule": "@:action:edit la @.lower:traffic-rule",
"action:enable": "Activer",
"action:enable-host": "Activer l'hôte",
"action:enable-light-mode": "Activer le mode clair",
@@ -391,6 +392,7 @@
"fast-clone-available": "Clonage rapide disponible uniquement pour les VMs arrêtées.",
"fetching-fresh-data": "Récupération de données à jour",
"field:exceeds-max-characters": "Le champ doit contenir {max} caractères ou moins.",
"field:required": "Champ obligatoire.",
"file": "Fichier",
"filter-actions": "Actions du filtre",
"following-hosts-unreachable": "Les hôtes suivants sont inaccessibles",
@@ -504,6 +506,7 @@
"job:delete:in-progress": "Suppression en cours…",
"job:disable:in-progress": "Désactivation en cours…",
"job:disconnect:in-progress": "Déconnexion en cours…",
"job:edit:in-progress": "Modification en cours…",
"job:enable:in-progress": "Activation en cours…",
"job:export:in-progress": "Export en cours…",
"job:host-detach:in-progress": "Détachement de l'hôte en cours…",

View File

@@ -52,6 +52,7 @@ import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-co
import { useDirectionLabels } from '@/modules/traffic-rules/composables/direction-labels.composable.ts'
import { useTrafficRuleTarget } from '@/modules/traffic-rules/composables/traffic-rule-target.composable.ts'
import { useTrafficRuleDelete } from '@/modules/traffic-rules/composables/use-traffic-rule-delete.composable.ts'
import { useTrafficRuleEdit } from '@/modules/traffic-rules/composables/use-traffic-rule-edit.composable.ts'
import type { EnrichedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { XO_LINKS } from '@/shared/constants.ts'
import VtsQueryBuilder from '@core/components/query-builder/VtsQueryBuilder.vue'
@@ -154,6 +155,8 @@ const { HeadCells, BodyCells } = useTrafficRulesColumns({
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules, deleteTrafficRulesErrorMessage } =
useTrafficRuleDelete(() => [rule])
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
return {
order: r => r(rule.order),
policy: r => r(t(rule.allow ? 'allow' : 'drop'), rule.allow ? 'success' : 'danger'),
@@ -166,6 +169,13 @@ const { HeadCells, BodyCells } = useTrafficRulesColumns({
r({
onClick: () => (selectedRuleId.value = rule.id),
actions: [
{
label: t('action:edit'),
icon: 'action:edit',
onClick: () => editTrafficRule(),
busy: isEditingTrafficRule.value,
disabled: !canEditTrafficRule.value,
},
{
label: t('action:delete'),
icon: 'action:delete',

View File

@@ -1,15 +1,33 @@
<template>
<UiButton
size="medium"
variant="tertiary"
accent="brand"
left-icon="action:edit"
:disabled="!canEditTrafficRule"
:busy="isEditingTrafficRule"
@click="editTrafficRule()"
>
{{ t('action:edit') }}
</UiButton>
<VtsDeleteButton :disabled="!canDeleteTrafficRules" :busy="isDeletingTrafficRules" @click="deleteTrafficRules()" />
</template>
<script lang="ts" setup>
import { useTrafficRuleDelete } from '@/modules/traffic-rules/composables/use-traffic-rule-delete.composable.ts'
import { useTrafficRuleEdit } from '@/modules/traffic-rules/composables/use-traffic-rule-edit.composable.ts'
import VtsDeleteButton from '@core/components/delete-button/VtsDeleteButton.vue'
import UiButton from '@core/components/ui/button/UiButton.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules } = useTrafficRuleDelete(() => [rule])
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
</script>

View File

@@ -0,0 +1,73 @@
<template>
<UiDrawer
class="traffic-rule-edit-drawer"
:on-dismiss="() => emit('cancel')"
@dismiss="emit('cancel')"
@confirm="onConfirm()"
>
<template #title>{{ t('action:edit-traffic-rule') }}</template>
<template #content>
<UiTitle class="section-title">
{{ t('general-information') }}
</UiTitle>
<span class="typo-body-regular-small required-hint">{{ t('field:required') }}</span>
<EditTrafficRuleForm ref="form" class="form" :rule />
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>
{{ t('action:save') }}
</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script setup lang="ts">
import EditTrafficRuleForm from '@/modules/traffic-rules/components/form/edit/EditTrafficRuleForm.vue'
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import UiTitle from '@core/components/ui/title/UiTitle.vue'
import type { TrafficRule } from '@vates/types'
import { useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
defineProps<{
rule: TrafficRule
}>()
const emit = defineEmits<{
cancel: []
confirm: [payload: TrafficRulePayload | undefined]
}>()
const { t } = useI18n()
const form = useTemplateRef('form')
async function onConfirm() {
emit('confirm', await form.value?.validate())
}
</script>
<style lang="postcss" scoped>
.traffic-rule-edit-drawer {
.section-title {
margin-block-end: 2.4rem;
}
.form {
margin-block-start: 2.4rem;
}
.required-hint::before {
content: '* ';
color: var(--color-brand-txt-base);
}
}
</style>

View File

@@ -0,0 +1,147 @@
<template>
<div class="edit-traffic-rule-form">
<div class="row">
<TrafficRuleFormSelect v-bind="allowSelectBindings">
<template #option="{ option }">
<VtsOption :option>
<span class="option-content">
<VtsStatus v-if="option.properties.status" :status="option.properties.status" icon-only />
{{ option.properties.label }}
</span>
</VtsOption>
</template>
</TrafficRuleFormSelect>
<TrafficRuleFormSelect v-bind="protocolSelectBindings" />
<TrafficRuleFormNumberInput v-if="hasPort" v-bind="portInputBindings" />
</div>
<div class="row">
<TrafficRuleFormSelect v-bind="directionSelectBindings" />
<TrafficRuleFormTextInput v-bind="ipRangeInputBindings" />
</div>
<div class="row target-row">
<span class="prefix-wrapper">
<span class="prefix">{{ t('on') }}</span>
</span>
<TrafficRuleFormSelect v-bind="targetTypeSelectBindings" />
<TrafficRuleFormSelect v-if="isVifTarget" v-bind="vmSelectBindings">
<template #option="{ option }">
<VtsOption :option>
<span class="option-content">
<VtsIcon v-if="option.properties.icon" :name="option.properties.icon" size="medium" />
{{ option.properties.label }}
</span>
</VtsOption>
</template>
</TrafficRuleFormSelect>
<TrafficRuleFormSelect v-bind="targetSelectBindings">
<template #option="{ option }">
<VtsOption :option>
<span class="option-content">
<VtsIcon v-if="option.properties.icon" :name="option.properties.icon" size="medium" />
{{ option.properties.label }}
</span>
</VtsOption>
</template>
</TrafficRuleFormSelect>
</div>
</div>
</template>
<script setup lang="ts">
import TrafficRuleFormNumberInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormNumberInput.vue'
import TrafficRuleFormSelect from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormSelect.vue'
import TrafficRuleFormTextInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormTextInput.vue'
import { useEditTrafficRuleForm } from '@/modules/traffic-rules/form/edit/use-edit-traffic-rule-form.ts'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import VtsOption from '@core/components/select/VtsOption.vue'
import VtsStatus from '@core/components/status/VtsStatus.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const {
hasPort,
isVifTarget,
allowSelectBindings,
protocolSelectBindings,
portInputBindings,
directionSelectBindings,
ipRangeInputBindings,
targetTypeSelectBindings,
vmSelectBindings,
targetSelectBindings,
validateAndBuildPayload,
} = useEditTrafficRuleForm(() => rule)
defineExpose({
validate: validateAndBuildPayload,
})
</script>
<style lang="postcss" scoped>
.option-content {
display: inline-flex;
align-items: center;
gap: 0.8rem;
}
.edit-traffic-rule-form {
.row {
display: flex;
flex-direction: column;
gap: 2.4rem;
& > * {
width: 100%;
min-width: 0;
}
&:not(:first-child) {
margin-block-start: 2.4rem;
}
@media (--medium-or-large) {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 2.4rem;
max-width: 78rem;
& > * {
width: auto;
}
}
}
@media (--medium-or-large) {
.row:nth-child(2) > :nth-child(2) {
grid-column: 2 / 4;
}
.target-row:has(> :nth-child(3):last-child) > :nth-child(3) {
grid-column: 3 / -1;
}
.target-row {
align-items: start;
.prefix-wrapper {
place-self: end;
.prefix {
height: 4rem;
display: flex;
align-items: center;
color: var(--color-neutral-txt-secondary);
}
}
}
}
}
</style>

View File

@@ -66,12 +66,12 @@
<script setup lang="ts">
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import TrafficRuleFormNumberInput from '@/modules/traffic-rules/components/form/new/inputs/TrafficRuleFormNumberInput.vue'
import TrafficRuleFormSelect from '@/modules/traffic-rules/components/form/new/inputs/TrafficRuleFormSelect.vue'
import TrafficRuleFormTextInput from '@/modules/traffic-rules/components/form/new/inputs/TrafficRuleFormTextInput.vue'
import TrafficRuleFormNumberInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormNumberInput.vue'
import TrafficRuleFormSelect from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormSelect.vue'
import TrafficRuleFormTextInput from '@/modules/traffic-rules/components/form/inputs/TrafficRuleFormTextInput.vue'
import NewTrafficRuleButtonsSection from '@/modules/traffic-rules/components/form/new/NewTrafficRuleButtonsSection.vue'
import { useNewTrafficRuleForm } from '@/modules/traffic-rules/form/new/use-new-traffic-rule-form.ts'
import type { NewTrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import VtsForm from '@core/components/form/VtsForm.vue'
import VtsIcon from '@core/components/icon/VtsIcon.vue'
@@ -87,7 +87,7 @@ const { poolId, vifId } = defineProps<{
}>()
const emit = defineEmits<{
create: [data: NewTrafficRulePayload]
create: [data: TrafficRulePayload]
}>()
const { t } = useI18n()

View File

@@ -1,7 +1,7 @@
<template>
<VtsSidePanel :has-selection="!!rule" @close="emit('close')">
<template v-if="rule" #actions>
<TrafficRuleActions :rule class="delete-button" />
<TrafficRuleActions :rule />
</template>
<template v-if="rule" #default>

View File

@@ -0,0 +1,51 @@
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { useXoTrafficRuleEditJob } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit.job.ts'
import { KEEP_OVERLAY_OPEN } from '@core/packages/overlay/symbols.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { TrafficRule } from '@vates/types'
import { reactiveComputed } from '@vueuse/core'
import { type MaybeRefOrGetter, ref } from 'vue'
export function useTrafficRuleEdit(rawTrafficRule: MaybeRefOrGetter<TrafficRule>) {
const trafficRule = toComputed(rawTrafficRule)
const newRulePayload = ref<TrafficRulePayload | undefined>(undefined)
const {
run,
canRun: canEditTrafficRule,
isRunning: isEditingTrafficRule,
errorMessage: editTrafficRuleErrorMessage,
} = useXoTrafficRuleEditJob(() => [trafficRule.value], newRulePayload)
const { open } = useOverlay({
component: () => import('@/modules/traffic-rules/components/drawer/TrafficRuleEditDrawer.vue'),
events: {
onConfirm: async (payload: TrafficRulePayload | undefined) => {
if (payload === undefined) {
return KEEP_OVERLAY_OPEN
}
newRulePayload.value = payload
try {
await run()
} catch (error) {
console.error('Error when updating traffic rule:', error)
}
},
onCancel: true,
},
})
const props = reactiveComputed(() => ({
rule: trafficRule.value,
}))
function editTrafficRule() {
return open({ props })
}
return { editTrafficRule, canEditTrafficRule, isEditingTrafficRule, editTrafficRuleErrorMessage }
}

View File

@@ -0,0 +1,145 @@
import {
type FrontXoNetwork,
useXoNetworkCollection,
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { TargetOption } from '@/modules/traffic-rules/form/target-option.util.ts'
import {
networkToTargetOption,
vifToTargetOption,
vmToTargetOption,
} from '@/modules/traffic-rules/form/target-option.util.ts'
import {
type BaseTrafficRuleFormData,
useTrafficRuleFormBase,
} from '@/modules/traffic-rules/form/use-traffic-rule-form-base.ts'
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { type FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { TrafficRule, TrafficRuleTargetType } from '@vates/types'
import { computed, type MaybeRefOrGetter, reactive, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
export type EditTrafficRuleFormData = BaseTrafficRuleFormData & {
targetType: TrafficRuleTargetType
targetId: FrontXoNetwork['id'] | FrontXoVif['id'] | undefined
vmId: FrontXoVm['id'] | undefined
}
export function useEditTrafficRuleForm(rawRule: MaybeRefOrGetter<TrafficRule>) {
const { t } = useI18n()
const rule = toComputed(rawRule)
const { useGetNetworkById } = useXoNetworkCollection()
const { useGetVifById } = useXoVifCollection()
const { useGetVmById } = useXoVmCollection()
const isVifTarget = computed(() => rule.value.type === 'VIF')
const ruleNetwork = useGetNetworkById(() => (rule.value.type === 'network' ? rule.value.sourceId : undefined))
const ruleVif = useGetVifById(() => (rule.value.type === 'VIF' ? rule.value.sourceId : undefined))
const ruleVm = useGetVmById(() => ruleVif.value?.$VM)
const formData = reactive<EditTrafficRuleFormData>({
allow: rule.value.allow,
direction: rule.value.direction,
protocol: rule.value.protocol,
port: rule.value.port !== undefined ? Number(rule.value.port) : undefined,
ipRange: rule.value.ipRange,
targetType: rule.value.type,
targetId: rule.value.sourceId,
vmId: undefined,
})
watchEffect(() => {
if (ruleVif.value && !formData.vmId) {
formData.vmId = ruleVif.value.$VM
}
})
const targetTypeOptions = [
{ id: 'network', label: t('network'), value: 'network' },
{ id: 'VIF', label: t('vif'), value: 'VIF' },
]
const vmOptions = computed(() => {
if (ruleVm.value) {
const hasVifs = ruleVm.value.VIFs.length > 0
return [vmToTargetOption(ruleVm.value, hasVifs)]
}
return []
})
const targetOptions = computed<TargetOption[]>(() => {
if (rule.value.type === 'VIF') {
return ruleVif.value ? [vifToTargetOption(ruleVif.value, `${t('vif')}${ruleVif.value.device}`)] : []
}
return ruleNetwork.value ? [networkToTargetOption(ruleNetwork.value)] : []
})
const {
validate,
useFormSelect,
useSelect,
hasPort,
allowSelectBindings,
directionSelectBindings,
protocolSelectBindings,
portInputBindings,
ipRangeInputBindings,
buildBaseRulePayload,
} = useTrafficRuleFormBase(formData)
const { id: targetTypeSelectId } = useFormSelect('targetType', targetTypeOptions, {
required: true,
disabled: () => true,
option: { label: 'label', value: 'value' },
})
const { id: vmSelectId } = useFormSelect('vmId', vmOptions, {
required: () => isVifTarget.value,
disabled: () => true,
option: { label: 'label', value: 'value', properties: source => ({ icon: source.icon }) },
})
const { id: targetSelectId } = useFormSelect('targetId', targetOptions, {
required: true,
disabled: () => true,
option: {
label: 'label',
value: 'value',
properties: source => ({ icon: source.icon }),
},
})
async function validateAndBuildPayload(): Promise<TrafficRulePayload | undefined> {
const valid = await validate()
if (!valid) {
return undefined
}
return {
...buildBaseRulePayload(),
targetType: rule.value.type,
targetId: rule.value.sourceId,
}
}
return {
isVifTarget,
hasPort,
allowSelectBindings,
directionSelectBindings,
protocolSelectBindings,
portInputBindings,
ipRangeInputBindings,
targetTypeSelectBindings: useSelect(targetTypeSelectId, () => ({ label: t('object') })),
vmSelectBindings: useSelect(vmSelectId, () => ({ label: t('from-vm') })),
targetSelectBindings: useSelect(targetSelectId, () => ({
label: formData.targetType === 'network' ? t('network') : t('vif'),
})),
validateAndBuildPayload,
}
}

View File

@@ -6,19 +6,19 @@ import { getNetworkIcon } from '@/modules/network/utils/xo-network.util.ts'
import { useXoPifCollection } from '@/modules/pif/remote-resources/use-xo-pif-collection.ts'
import { type FrontXoPool, useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useDirectionLabels } from '@/modules/traffic-rules/composables/direction-labels.composable.ts'
import { vmToTargetOption } from '@/modules/traffic-rules/form/target-option.util.ts'
import {
type BaseTrafficRuleFormData,
useTrafficRuleFormBase,
} from '@/modules/traffic-rules/form/use-traffic-rule-form-base.ts'
import type { NewTrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { isNetworkRuleSupported } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import { type FrontXoVm, useXoVmCollection } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { type IconName, objectIcon } from '@core/icons'
import type { IconName } from '@core/icons'
import { type FormValidationConfig, required, requiredIf, withMessage } from '@core/packages/form-validation'
import { toComputed } from '@core/utils/to-computed.util.ts'
import type { TrafficRuleTargetType } from '@vates/types'
import { toLower } from 'lodash-es'
import { computed, type MaybeRefOrGetter, reactive, watch, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -121,13 +121,7 @@ export function useNewTrafficRuleForm(
poolVms.value.map(vm => {
const hasVifs = vm.VIFs.length > 0
return {
id: vm.id,
label: vm.name_label,
value: vm.id,
icon: objectIcon('vm', toLower(vm.power_state)),
disabled: !hasVifs,
}
return vmToTargetOption(vm, hasVifs)
})
)
@@ -236,7 +230,7 @@ export function useNewTrafficRuleForm(
}
})
async function validateAndBuildPayload(): Promise<NewTrafficRulePayload | undefined> {
async function validateAndBuildPayload(): Promise<TrafficRulePayload | undefined> {
if (isUnsupportedNetworkTarget.value) {
return undefined
}

View File

@@ -0,0 +1,48 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { FrontXoVif } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
import type { FrontXoVm } from '@/modules/vm/remote-resources/use-xo-vm-collection.ts'
import { type IconName, objectIcon } from '@core/icons'
import { toLower } from 'lodash-es'
export type TargetOption = {
id: FrontXoVif['id'] | FrontXoNetwork['id']
label: string
value: FrontXoVif['id'] | FrontXoNetwork['id']
icon: 'object:network' | 'object:vif'
}
export type VmOption = {
id: FrontXoVm['id']
label: string
value: FrontXoVm['id']
icon: IconName
disabled: boolean
}
export function vifToTargetOption(vif: FrontXoVif, vifLabel: string): TargetOption {
return {
id: vif.id,
label: vifLabel,
value: vif.id,
icon: 'object:vif',
}
}
export function networkToTargetOption(network: FrontXoNetwork): TargetOption {
return {
id: network.id,
label: network.name_label,
value: network.id,
icon: 'object:network',
}
}
export function vmToTargetOption(vm: FrontXoVm, hasVifs: boolean): VmOption {
return {
id: vm.id,
label: vm.name_label,
value: vm.id,
icon: objectIcon('vm', toLower(vm.power_state)),
disabled: !hasVifs,
}
}

View File

@@ -28,11 +28,11 @@ export type BaseTrafficRuleFormData = {
ipRange: string
}
export type BaseNewTrafficRulePayload = {
export type BaseTrafficRulePayload = {
allow: boolean
direction: TrafficRuleDirection
protocol: TrafficRuleProtocol
port?: number
port: number | null
ipRange: string
}
@@ -101,13 +101,13 @@ export function useTrafficRuleFormBase<T extends BaseTrafficRuleFormData>(
const hasPort = computed(() => protocolHasPort(formData.protocol))
function buildBaseRulePayload(): BaseNewTrafficRulePayload {
function buildBaseRulePayload(): BaseTrafficRulePayload {
return {
allow: formData.allow,
direction: formData.direction,
protocol: formData.protocol,
ipRange: formData.ipRange,
...(protocolHasPort(formData.protocol) && typeof formData.port === 'number' && { port: formData.port }),
port: protocolHasPort(formData.protocol) && typeof formData.port === 'number' ? formData.port : null,
}
}

View File

@@ -1,7 +1,7 @@
import type { NewTrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import type { TrafficRulePayload } from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { defineJobArg } from '@core/packages/job'
export const payloadsArg = defineJobArg<NewTrafficRulePayload>({
export const payloadsArg = defineJobArg<TrafficRulePayload>({
identify: payload => payload.targetId,
toArray: true,
})

View File

@@ -13,14 +13,14 @@ import {
} from '@vates/types'
import { useI18n } from 'vue-i18n'
export type NewTrafficRulePayload = {
export type TrafficRulePayload = {
targetType: TrafficRuleTargetType
targetId: XoNetwork['id'] | XoVif['id']
allow: boolean
direction: TrafficRuleDirection
protocol: TrafficRuleProtocol
ipRange: string
port?: number
port: number | null
}
export const useXoTrafficRuleCreateJob = defineJob('traffic-rule.create', [payloadsArg], () => {

View File

@@ -0,0 +1,7 @@
import type { BaseTrafficRulePayload } from '@/modules/traffic-rules/form/use-traffic-rule-form-base.ts'
import { defineJobArg } from '@core/packages/job'
export const editTrafficRulePayloadArg = defineJobArg<BaseTrafficRulePayload>({
identify: false,
toArray: false,
})

View File

@@ -0,0 +1,59 @@
import type { FrontXoTask } from '@/modules/task/remote-resources/use-xo-task-collection.ts'
import type { BaseTrafficRulePayload } from '@/modules/traffic-rules/form/use-traffic-rule-form-base.ts'
import { xoTrafficRulesArg } from '@/modules/traffic-rules/jobs/xo-traffic-rule-args.ts'
import { editTrafficRulePayloadArg } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-args.ts'
import { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
import { fetchPost } from '@/shared/utils/fetch.util.ts'
import { defineJob, JobError, JobRunningError } from '@core/packages/job'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
export const useXoTrafficRuleEditJob = defineJob(
'traffic-rule.edit',
[xoTrafficRulesArg, editTrafficRulePayloadArg],
() => {
const { monitorTask } = useXoTaskUtils()
const { t } = useI18n()
return {
async run(oldRules: TrafficRule[], newRule: BaseTrafficRulePayload): Promise<PromiseSettledResult<void>[]> {
const results = await Promise.allSettled(
oldRules.map(async oldRule => {
const scope = oldRule.type === 'network' ? 'networks' : 'vifs'
const endpoint = `plugins/sdn-controller/${scope}/${oldRule.sourceId}/actions/update_traffic_rule`
const { taskId } = await fetchPost<{ taskId: FrontXoTask['id'] }>(endpoint, {
oldRule: {
allow: oldRule.allow,
direction: oldRule.direction,
ipRange: oldRule.ipRange,
protocol: oldRule.protocol,
port: oldRule.port !== undefined ? Number(oldRule.port) : undefined,
},
newRule,
})
await monitorTask(taskId)
})
)
results.forEach((result, index) => {
if (result.status === 'rejected') {
console.error(`Failed to edit traffic rule ${oldRules[index].id}:`, result.reason)
}
})
return results
},
validate(isRunning, oldRules) {
if (isRunning) {
throw new JobRunningError(t('job:edit:in-progress'))
}
if (oldRules.length === 0) {
throw new JobError(t('job:arg:missing-payload'))
}
},
}
}
)

View File

@@ -28,7 +28,7 @@
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import NewTrafficRuleForm from '@/modules/traffic-rules/components/form/new/NewTrafficRuleForm.vue'
import {
type NewTrafficRulePayload,
type TrafficRulePayload,
useXoTrafficRuleCreateJob,
} from '@/modules/traffic-rules/jobs/xo-traffic-rule-create.job.ts'
import { type FrontXoVif, useXoVifCollection } from '@/modules/vif/remote-resources/use-xo-vif-collection.ts'
@@ -59,7 +59,7 @@ const vif = useGetVifById(() => vifId.value)
const poolId = computed<FrontXoPool['id'] | undefined>(() => queryPoolId.value ?? vif.value?.$pool)
const formPayload = ref<NewTrafficRulePayload>()
const formPayload = ref<TrafficRulePayload>()
const error = ref<ApiError | Error | undefined>()
const hasTrafficRuleCreationError = computed(() => error.value !== undefined)
@@ -72,7 +72,7 @@ function handleGoBack() {
error.value = undefined
}
async function createTrafficRule(newPayload: NewTrafficRulePayload) {
async function createTrafficRule(newPayload: TrafficRulePayload) {
formPayload.value = newPayload
if (!canRun.value) {

View File

@@ -11,6 +11,8 @@
> Users must be able to say: "Nice enhancement, I'm eager to test it"
- [XO6/Traffic rules] Add possibility of editing a traffic rule (PR [#10056](https://github.com/vatesfr/xen-orchestra/pull/10056))
### Bug fixes
> Users must be able to say: "I had this issue, happy to know it's fixed"