feat(xo6): add edit order action within network for traffic rules

This commit is contained in:
Elise-F
2026-11-02 14:44:07 +01:00
parent 8e81250ca3
commit 3c4ae9c70f
23 changed files with 972 additions and 46 deletions

View File

@@ -0,0 +1,49 @@
<template>
<UiTableCell class="vts-button-icon-list-cell" align="center">
<div class="buttons">
<UiButtonIcon
v-for="(button, index) of buttons"
:key="index"
v-tooltip="button.label"
:aria-label="button.label"
:icon="button.icon"
:disabled="button.disabled"
:target-scale="1.5"
accent="brand"
size="small"
@click="button.onClick()"
/>
</div>
</UiTableCell>
</template>
<script lang="ts" setup>
import UiButtonIcon from '@core/components/ui/button-icon/UiButtonIcon.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import { vTooltip } from '@core/directives/tooltip.directive.ts'
import type { IconName } from '@core/icons'
export type ButtonIconItem = {
icon: IconName
label: string
onClick: () => void
disabled?: boolean
}
defineProps<{
buttons: ButtonIconItem[]
}>()
</script>
<style lang="postcss" scoped>
.vts-button-icon-list-cell {
width: 8rem;
padding: 0.8rem 1.6rem 0.8rem 0.8rem;
.buttons {
display: flex;
justify-content: center;
gap: 0.8rem;
}
}
</style>

View File

@@ -0,0 +1,56 @@
<template>
<UiTableCell class="vts-rank-cell">
<div class="content">
<span>{{ rank }}</span>
<span v-if="shiftIcon" class="shift">
<VtsIcon :name="shiftIcon" size="small" />
{{ shiftCount }}
</span>
</div>
</UiTableCell>
</template>
<script lang="ts" setup>
import VtsIcon from '@core/components/icon/VtsIcon.vue'
import UiTableCell from '@core/components/ui/table-cell/UiTableCell.vue'
import type { IconName } from '@core/icons'
import { computed } from 'vue'
const { shift = 0 } = defineProps<{
rank: number
shift?: number
}>()
const shiftIcon = computed<IconName | undefined>(() => {
if (shift > 0) {
return 'action:move-up'
}
if (shift < 0) {
return 'action:move-down'
}
return undefined
})
const shiftCount = computed(() => Math.abs(shift))
</script>
<style lang="postcss" scoped>
.vts-rank-cell {
width: 7rem;
.content {
display: flex;
justify-content: space-between;
gap: 0.8rem;
}
.shift {
display: flex;
align-items: center;
gap: 0.4rem;
color: var(--color-neutral-txt-secondary);
}
}
</style>

View File

@@ -4,10 +4,12 @@ import { faCopy, faSquare as checkboxEmpty, type IconDefinition } from '@fortawe
import {
faAdd,
faArrowCircleRight,
faArrowDown,
faArrowDownLong,
faArrowRight,
faArrowRotateLeft,
faArrowRotateRight,
faArrowUp,
faArrowUpLong,
faArrowUpRightFromSquare,
faBan,
@@ -153,6 +155,8 @@ export const actionIcons = defineIconPack({
color: 'var(--color-neutral-txt-primary)',
rotate: 90,
},
'move-down': constructIcon(faArrowDown),
'move-up': constructIcon(faArrowUp),
'import-export': [
{
icon: faArrowUpLong,

View File

@@ -85,6 +85,7 @@
"action:edit-br": "@:action:edit BR",
"action:edit-config": "Edit config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-rules-order": "@:action:edit rules order within network",
"action:edit-traffic-rule": "@:action:edit @.lower:traffic-rule",
"action:emergency-shutdown": "Emergency shutdown",
"action:emergency-shutdown-host": "@:action:emergency-shutdown host",
@@ -123,7 +124,9 @@
"action:migrate": "Migrate",
"action:migrate-n-vms": "Migrate 1 VM | Migrate {n} VMs",
"action:migrate-vdi-on-sr": "Migrate on SR",
"action:move-down": "Move down",
"action:move-filters-to-parent-group": "Move filters to parent group",
"action:move-up": "Move up",
"action:open": "Open",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Open console in new tab",
@@ -574,6 +577,7 @@
"job:arg:missing-payload": "Payload is missing",
"job:arg:name-required": "@:name-required",
"job:arg:nbd-and-insecure-nbd-exclusive": "NBD and insecure NBD cannot be enabled together",
"job:arg:network-required": "@:network-required",
"job:arg:password-required": "Password is required",
"job:arg:pif-id-required": "PIF ID is required",
"job:arg:pif-ids-required": "At least one PIF ID is required",
@@ -690,6 +694,8 @@
"job:sr-delete:missing-sr": "No SRs to delete",
"job:sr-disconnect:missing-sr": "No SRs to disconnect",
"job:traffic-rule-delete:missing-rule": "No traffic rule selected",
"job:traffic-rule-edit-order:no-free-priority": "No free priority left on this network to apply this order",
"job:traffic-rule-edit-order:not-enough-rules": "Requires at least {n} rules",
"job:vbd-connect:missing-vbd": "No VBDs to connect",
"job:vbd-connect:vbd-already-connected": "VBD is already connected",
"job:vbd-delete:missing-vbd": "No VBDs to delete",
@@ -1395,6 +1401,8 @@
"traffic-rule": "Traffic rule",
"traffic-rule-delete-warning": "This action is irreversible. The selected traffic rule will be permanently deleted.",
"traffic-rules": "Traffic rules",
"traffic-rules:edit-order-info": "Traffic rules are evaluated by priority, starting with rule 1, then rule 2, and so on. The first matching rule is applied, and subsequent rules are skipped. Priority only matters within the same network.",
"traffic-rules:edit-order-save-info": "Changes will only take effect when you save.",
"traffic-rules:error-plugin": "You are using the direct OpenFlow protocol (legacy mode), which does not support network traffic rules. To create them, switch to the XAPI plugin mode (beta) — see {check-doc}.",
"traffic-rules:error-plugin:check-doc": "the SDN controller documentation",
"traffic-rules:format-warning": "Configuration error: Traffic rules are not in the expected format. Please {check-doc} on how to migrate them.",
@@ -1403,7 +1411,8 @@
"traffic-rules:position-on-network": "{position} rule on this network.",
"traffic-rules:rule-ordinal-suffixes": "one:st, two:nd, few:rd, other:th",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Requires XAPI plugin mode",
"traffic-rules:xapi-plugin-required": "Requires @:xapi-plugin mode",
"traffic-rules:xapi-plugin-required-with-link": "Requires {xapi-plugin} mode",
"translation-tool": "Translation tool",
"treeview": "Treeview",
"trigger": "Trigger",
@@ -1555,6 +1564,7 @@
"with-memory": "With memory",
"write": "Write",
"writing-speed": "Writing speed",
"xapi-plugin": "XAPI plugin",
"xcp-ng": "XCP-ng",
"xen-orchestra": "Xen Orchestra",
"xo": "XO",

View File

@@ -85,6 +85,7 @@
"action:edit-br": "@:action:edit le BR",
"action:edit-config": "Modifier config",
"action:edit-n-brs": "@:action:edit {n} BRs",
"action:edit-rules-order": "@:action:edit l'ordre des règles dans le réseau",
"action:edit-traffic-rule": "@:action:edit la @.lower:traffic-rule",
"action:emergency-shutdown": "Arrêt d'urgence",
"action:emergency-shutdown-host": "@:action:emergency-shutdown de l'hôte",
@@ -123,7 +124,9 @@
"action:migrate": "Migrer",
"action:migrate-n-vms": "Migrer 1 VM | Migrer {n} VMs",
"action:migrate-vdi-on-sr": "Migrer sur le SR",
"action:move-down": "Descendre",
"action:move-filters-to-parent-group": "Déplacer les filtres vers le groupe parent",
"action:move-up": "Monter",
"action:open": "Ouvrir",
"action:open-app": "@:action:open {name}",
"action:open-console-in-new-tab": "Ouvrir la console dans un nouvel onglet",
@@ -574,6 +577,7 @@
"job:arg:missing-payload": "Aucune donnée fournie pour l'execution du job",
"job:arg:name-required": "@:name-required",
"job:arg:nbd-and-insecure-nbd-exclusive": "NBD et NBD non sécurisé ne peuvent pas être activés ensemble",
"job:arg:network-required": "@:network-required",
"job:arg:password-required": "Le mot de passe est obligatoire",
"job:arg:pif-id-required": "L'ID de PIF est obligatoire",
"job:arg:pif-ids-required": "Au moins un ID de PIF est obligatoire",
@@ -690,6 +694,8 @@
"job:sr-delete:missing-sr": "Aucun SR à supprimer",
"job:sr-disconnect:missing-sr": "Aucun SR à déconnecter",
"job:traffic-rule-delete:missing-rule": "Aucune règle de trafic sélectionnée",
"job:traffic-rule-edit-order:no-free-priority": "Plus aucune priorité libre sur ce réseau pour appliquer cet ordre",
"job:traffic-rule-edit-order:not-enough-rules": "Nécessite au moins {n} règles",
"job:vbd-connect:missing-vbd": "Aucun VBD à connecter",
"job:vbd-connect:vbd-already-connected": "Le VBD est déjà connecté",
"job:vbd-delete:missing-vbd": "Aucun VBD à supprimer",
@@ -1395,6 +1401,8 @@
"traffic-rule": "Règle de trafic",
"traffic-rule-delete-warning": "Cette action est irréversible. La règle de trafic sélectionnée sera définitivement supprimée.",
"traffic-rules": "Règles de trafic",
"traffic-rules:edit-order-info": "Les règles de trafic sont évaluées par priorité, en commençant par la règle 1, puis la règle 2, et ainsi de suite. La première règle correspondante est appliquée et les suivantes sont ignorées. La priorité ne compte qu'au sein d'un même réseau.",
"traffic-rules:edit-order-save-info": "Les modifications ne prendront effet qu'à l'enregistrement.",
"traffic-rules:error-plugin": "Vous utilisez le protocole OpenFlow direct (mode historique), qui ne prend pas en charge les règles de trafic réseau. Pour en créer, passez en mode plugin XAPI (bêta) — voir {check-doc}.",
"traffic-rules:error-plugin:check-doc": "la documentation du contrôleur SDN",
"traffic-rules:format-warning": "Erreur de configuration : les règles de trafic ne sont pas au format attendu. Veuillez {check-doc} la plus récente pour savoir comment les migrer.",
@@ -1403,7 +1411,8 @@
"traffic-rules:position-on-network": "{position} règle sur ce réseau.",
"traffic-rules:rule-ordinal-suffixes": "one:re, other:e",
"traffic-rules:target-on": "@.capitalize:direction-on",
"traffic-rules:xapi-plugin-required": "Nécessite le mode plugin XAPI",
"traffic-rules:xapi-plugin-required": "Nécessite le mode @:xapi-plugin",
"traffic-rules:xapi-plugin-required-with-link": "Nécessite le mode {xapi-plugin}",
"translation-tool": "Outil de traduction",
"treeview": "Arborescence",
"trigger": "Déclencheur",
@@ -1555,6 +1564,7 @@
"with-memory": "Avec mémoire",
"write": "Écriture",
"writing-speed": "Vitesse d'écriture",
"xapi-plugin": "plugin XAPI",
"xcp-ng": "XCP-ng",
"xen-orchestra": "Xen Orchestra",
"xo": "XO",

View File

@@ -0,0 +1,12 @@
import VtsButtonIconListCell, { type ButtonIconItem } from '@core/components/table/cells/VtsButtonIconListCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export type { ButtonIconItem }
export const useButtonIconListColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (buttons: ButtonIconItem[]) => h(VtsButtonIconListCell, { buttons }),
}))

View File

@@ -0,0 +1,10 @@
import VtsRankCell from '@core/components/table/cells/VtsRankCell.vue'
import { defineColumn } from '@core/packages/table/define-column.ts'
import { renderHeadCell } from '@core/tables/helpers/render-head-cell.ts'
import type { HeaderConfig } from '@core/tables/types.ts'
import { h } from 'vue'
export const useRankColumn = defineColumn((config?: HeaderConfig) => ({
renderHead: () => renderHeadCell(config?.headerLabel),
renderBody: (rank: number, shift?: number) => h(VtsRankCell, { rank, shift }),
}))

View File

@@ -1,7 +1,9 @@
import { defineColumns } from '@core/packages/table/define-columns.ts'
import { useActionColumn } from '@core/tables/column-definitions/action-column.ts'
import { useButtonIconListColumn } from '@core/tables/column-definitions/button-icon-list-column.ts'
import { useDoubleLinkColumn } from '@core/tables/column-definitions/double-link-column.ts'
import { useLinkColumn } from '@core/tables/column-definitions/link-column.ts'
import { useRankColumn } from '@core/tables/column-definitions/rank-column.ts'
import { useTagColumn } from '@core/tables/column-definitions/tag-column.ts'
import { useTextColumn } from '@core/tables/column-definitions/text-column.ts'
import { useTruncatedTextColumn } from '@core/tables/column-definitions/truncated-text-column.ts'
@@ -13,6 +15,7 @@ export const useTrafficRulesColumns = defineColumns(() => {
return {
network: useLinkColumn({ headerLabel: () => t('network') }),
priority: useTextColumn({ headerLabel: () => t('priority') }),
rank: useRankColumn({ headerLabel: () => t('priority') }),
policy: useTagColumn({ headerLabel: () => t('policy') }),
protocol: useTextColumn({
headerLabel: () => t('protocol:port'),
@@ -24,5 +27,6 @@ export const useTrafficRulesColumns = defineColumns(() => {
}),
object: useDoubleLinkColumn({ headerLabel: () => t('object') }),
actions: useActionColumn({}),
moveButtons: useButtonIconListColumn(),
}
})

View File

@@ -0,0 +1,7 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { defineJobArg } from '@core/packages/job'
export const xoNetworkArg = defineJobArg({
identify: (network: FrontXoNetwork | undefined) => network?.id,
toArray: false,
})

View File

@@ -46,10 +46,9 @@
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { getPoolNetworkRoute } from '@/modules/network/utils/xo-network.util.ts'
import type { FrontXoPool } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import TrafficRuleTableActions from '@/modules/traffic-rules/components/actions/TrafficRuleTableActions.vue'
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, RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { getNetworkRules, getTrafficRuleProtocolLabel } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { XO_LINKS } from '@/shared/constants.ts'
@@ -158,7 +157,7 @@ const state = useTableState({
})
const { HeadCells, BodyCells } = useTrafficRulesColumns({
exclude: showPriority ? [] : ['priority'],
exclude: showPriority ? ['rank', 'moveButtons'] : ['priority', 'rank', 'moveButtons'],
body: (rule: EnrichedTrafficRule) => {
const network = useGetNetworkById(() => rule.networkId)
@@ -166,11 +165,6 @@ const { HeadCells, BodyCells } = useTrafficRulesColumns({
network.value ? getPoolNetworkRoute(network.value.$pool, network.value.id) : undefined
)
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules, deleteTrafficRulesErrorMessage } =
useTrafficRuleDelete(() => [rule])
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
return {
network: r =>
r({
@@ -188,24 +182,8 @@ const { HeadCells, BodyCells } = useTrafficRulesColumns({
actions: r =>
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',
onClick: () => deleteTrafficRules(),
disabled: !canDeleteTrafficRules.value,
busy: isDeletingTrafficRules.value,
hint: deleteTrafficRulesErrorMessage.value,
accent: 'danger',
},
],
component: TrafficRuleTableActions,
props: { rule, rules: rawRules, showEditOrder: showPriority },
}),
}
},

View File

@@ -1,33 +1,15 @@
<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,16 @@
<template>
<TrafficRuleEditButton :rule />
<TrafficRuleEditOrderButton v-if="showEditOrder" :rule :rules />
</template>
<script lang="ts" setup>
import TrafficRuleEditButton from '@/modules/traffic-rules/components/actions/edit/TrafficRuleEditButton.vue'
import TrafficRuleEditOrderButton from '@/modules/traffic-rules/components/actions/edit-order/TrafficRuleEditOrderButton.vue'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
defineProps<{
rule: RankedTrafficRule
rules: RankedTrafficRule[]
showEditOrder?: boolean
}>()
</script>

View File

@@ -0,0 +1,16 @@
<template>
<TrafficRuleMoreActions :rule :rules :show-edit-order />
<TrafficRuleDeleteButton :rule />
</template>
<script lang="ts" setup>
import TrafficRuleDeleteButton from '@/modules/traffic-rules/components/actions/delete/TrafficRuleDeleteButton.vue'
import TrafficRuleMoreActions from '@/modules/traffic-rules/components/actions/TrafficRuleMoreActions.vue'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
defineProps<{
rule: RankedTrafficRule
rules: RankedTrafficRule[]
showEditOrder?: boolean
}>()
</script>

View File

@@ -0,0 +1,28 @@
<template>
<MenuItem
:busy="isDeletingTrafficRules"
:disabled="!canDeleteTrafficRules"
accent="danger"
icon="action:delete"
@click="deleteTrafficRules()"
>
{{ t('action:delete') }}
<i v-if="deleteTrafficRulesErrorMessage" class="em-dash-prefix">{{ deleteTrafficRulesErrorMessage }}</i>
</MenuItem>
</template>
<script lang="ts" setup>
import { useTrafficRuleDelete } from '@/modules/traffic-rules/composables/use-traffic-rule-delete.composable.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const { deleteTrafficRules, canDeleteTrafficRules, isDeletingTrafficRules, deleteTrafficRulesErrorMessage } =
useTrafficRuleDelete(() => [rule])
</script>

View File

@@ -0,0 +1,48 @@
<template>
<MenuItem
:busy="isEditingTrafficRulesOrder"
:disabled="!canEditTrafficRulesOrder"
accent="neutral"
icon="action:edit"
@click="editTrafficRulesOrder()"
>
{{ t('action:edit-rules-order') }}
<i v-if="isXapiPluginRequired" class="em-dash-prefix">
<I18nT keypath="traffic-rules:xapi-plugin-required-with-link" scope="global">
<template #xapi-plugin>
<UiLink :href="XO_LINKS.DOC_SDN_CONTROLLER_XAPI_PLUGIN" size="small">
{{ t('xapi-plugin') }}
</UiLink>
</template>
</I18nT>
</i>
<i v-else-if="editTrafficRulesOrderErrorMessage" class="em-dash-prefix">{{ editTrafficRulesOrderErrorMessage }}</i>
</MenuItem>
</template>
<script lang="ts" setup>
import { useTrafficRuleEditOrder } from '@/modules/traffic-rules/composables/use-traffic-rule-edit-order.composable.ts'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { XO_LINKS } from '@/shared/constants.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { I18nT, useI18n } from 'vue-i18n'
const { rule, rules } = defineProps<{
rule: RankedTrafficRule
rules: RankedTrafficRule[]
}>()
const { t } = useI18n()
const {
editTrafficRulesOrder,
canEditTrafficRulesOrder,
isEditingTrafficRulesOrder,
editTrafficRulesOrderErrorMessage,
isXapiPluginRequired,
} = useTrafficRuleEditOrder(
() => rule.networkId,
() => rules
)
</script>

View File

@@ -0,0 +1,26 @@
<template>
<MenuItem
:busy="isEditingTrafficRule"
:disabled="!canEditTrafficRule"
accent="neutral"
icon="action:edit"
@click="editTrafficRule()"
>
{{ t('action:edit') }}
</MenuItem>
</template>
<script lang="ts" setup>
import { useTrafficRuleEdit } from '@/modules/traffic-rules/composables/use-traffic-rule-edit.composable.ts'
import MenuItem from '@core/components/menu/MenuItem.vue'
import type { TrafficRule } from '@vates/types'
import { useI18n } from 'vue-i18n'
const { rule } = defineProps<{
rule: TrafficRule
}>()
const { t } = useI18n()
const { editTrafficRule, canEditTrafficRule, isEditingTrafficRule } = useTrafficRuleEdit(() => rule)
</script>

View File

@@ -0,0 +1,180 @@
<template>
<UiDrawer
:on-dismiss="() => emit('cancel')"
class="traffic-rule-edit-order-drawer"
@confirm="emit('confirm', orderedRules)"
@dismiss="emit('cancel')"
>
<template #title>{{ t('action:edit-rules-order') }}</template>
<template #content>
<div class="container">
<div class="network typo-body-regular-small">
<span class="label">{{ t('network') }}</span>
<UiLink v-if="network" :to="networkTo" icon="object:network" size="small">
{{ network.name_label }}
</UiLink>
</div>
<UiAlert accent="info">
<p>{{ t('traffic-rules:edit-order-info') }}</p>
<p>{{ t('traffic-rules:edit-order-save-info') }}</p>
</UiAlert>
<VtsTable :pagination-bindings sticky="right">
<thead>
<tr>
<HeadCells />
</tr>
</thead>
<tbody>
<VtsRow v-for="rule of paginatedRules" :key="rule.id">
<BodyCells :item="rule" />
</VtsRow>
</tbody>
</VtsTable>
</div>
</template>
<template #buttons>
<VtsOverlayCancelButton @click="emit('cancel')" />
<VtsOverlayConfirmButton>
{{ t('action:save') }}
</VtsOverlayConfirmButton>
</template>
</UiDrawer>
</template>
<script lang="ts" setup>
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { getPoolNetworkRoute } from '@/modules/network/utils/xo-network.util.ts'
import { useDirectionLabels } from '@/modules/traffic-rules/composables/direction-labels.composable.ts'
import { useTrafficRuleTarget } from '@/modules/traffic-rules/composables/traffic-rule-target.composable.ts'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { getTrafficRuleProtocolLabel } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import VtsOverlayCancelButton from '@core/components/overlay/VtsOverlayCancelButton.vue'
import VtsOverlayConfirmButton from '@core/components/overlay/VtsOverlayConfirmButton.vue'
import VtsRow from '@core/components/table/VtsRow.vue'
import VtsTable from '@core/components/table/VtsTable.vue'
import UiAlert from '@core/components/ui/alert/UiAlert.vue'
import UiDrawer from '@core/components/ui/drawer/UiDrawer.vue'
import UiLink from '@core/components/ui/link/UiLink.vue'
import { usePagination } from '@core/composables/pagination.composable.ts'
import { useTrafficRulesColumns } from '@core/tables/column-sets/traffic-rules-columns.ts'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { network, rules } = defineProps<{
network: FrontXoNetwork | undefined
rules: RankedTrafficRule[]
}>()
const emit = defineEmits<{
cancel: []
confirm: [orderedRules: RankedTrafficRule[]]
}>()
const { t } = useI18n()
const getTarget = useTrafficRuleTarget()
const getDirectionLabels = useDirectionLabels()
const networkTo = computed(() => {
if (network === undefined) {
return undefined
}
return getPoolNetworkRoute(network.$pool, network.id)
})
const orderedRules = ref([...rules])
const {
pageRecords: paginatedRules,
paginationBindings,
seek,
} = usePagination('traffic-rules-order', orderedRules, {
ignoreRoute: true,
})
function getRuleIndex(rule: RankedTrafficRule) {
return orderedRules.value.findIndex(orderedRule => orderedRule.id === rule.id)
}
function moveRule(rule: RankedTrafficRule, offset: -1 | 1) {
const index = getRuleIndex(rule)
const newIndex = index + offset
if (index === -1 || newIndex < 0 || newIndex >= orderedRules.value.length) {
return
}
const newOrderedRules = [...orderedRules.value]
const [movedRule] = newOrderedRules.splice(index, 1)
newOrderedRules.splice(newIndex, 0, movedRule)
orderedRules.value = newOrderedRules
seek(orderedRule => orderedRule.id === rule.id)
}
const { HeadCells, BodyCells } = useTrafficRulesColumns({
exclude: ['network', 'priority', 'actions'],
body: (rule: RankedTrafficRule) => {
const [directionA, directionB] = getDirectionLabels(rule)
return {
rank: r => {
const newRank = getRuleIndex(rule) + 1
return r(newRank, rule.rank - newRank)
},
policy: r => r(t(rule.allow ? 'allow' : 'drop'), rule.allow ? 'success' : 'danger'),
protocol: r => r(getTrafficRuleProtocolLabel(rule)),
directionA: r => r(directionA),
target: r => r(rule.ipRange),
directionB: r => r(directionB),
object: r => r(getTarget(rule)),
moveButtons: r => {
const index = getRuleIndex(rule)
return r([
{
icon: 'action:move-up',
label: t('action:move-up'),
onClick: () => moveRule(rule, -1),
disabled: index === 0,
},
{
icon: 'action:move-down',
label: t('action:move-down'),
onClick: () => moveRule(rule, 1),
disabled: index === orderedRules.value.length - 1,
},
])
},
}
},
})
</script>
<style lang="postcss" scoped>
.traffic-rule-edit-order-drawer {
.container {
display: flex;
flex-direction: column;
gap: 2.4rem;
}
.network {
display: flex;
align-items: center;
gap: 2.4rem;
.label {
color: var(--color-neutral-txt-secondary);
}
}
}
</style>

View File

@@ -4,6 +4,10 @@
<TrafficRuleActions :rule />
</template>
<template v-if="rule" #more-actions>
<TrafficRuleMoreActions :rule :rules :show-edit-order="showPriority" />
</template>
<template v-if="rule" #default>
<VtsStateHero v-if="!isReady" format="panel" size="medium" type="busy" />
<template v-else>
@@ -28,6 +32,7 @@
<script lang="ts" setup>
import { useXoNetworkCollection } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import TrafficRuleActions from '@/modules/traffic-rules/components/actions/TrafficRuleActions.vue'
import TrafficRuleMoreActions from '@/modules/traffic-rules/components/actions/TrafficRuleMoreActions.vue'
import TrafficRuleNetworkInfosCard from '@/modules/traffic-rules/components/list/panel/cards/TrafficRuleNetworkInfosCard.vue'
import TrafficRuleNetworkPifsCard from '@/modules/traffic-rules/components/list/panel/cards/TrafficRuleNetworkPifsCard.vue'
import TrafficRulePriorityCard from '@/modules/traffic-rules/components/list/panel/cards/TrafficRulePriorityCard.vue'

View File

@@ -0,0 +1,77 @@
import {
type FrontXoNetwork,
useXoNetworkCollection,
} from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useXoTrafficRuleEditOrderJob } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-order.job.ts'
import type { RankedTrafficRule } from '@/modules/traffic-rules/types.ts'
import { getNetworkRules, isNetworkRuleSupported } from '@/modules/traffic-rules/utils/xo-traffic-rule.util.ts'
import { useOverlay } from '@core/packages/overlay/use-overlay.ts'
import { toComputed } from '@core/utils/to-computed.util.ts'
import { reactiveComputed } from '@vueuse/core'
import { computed, type MaybeRefOrGetter, ref } from 'vue'
export function useTrafficRuleEditOrder(
rawNetworkId: MaybeRefOrGetter<FrontXoNetwork['id']>,
rawTrafficRules: MaybeRefOrGetter<RankedTrafficRule[]>
) {
const { getNetworkById } = useXoNetworkCollection()
const { getPoolById } = useXoPoolCollection()
const networkId = toComputed(rawNetworkId)
const trafficRules = toComputed(rawTrafficRules)
const network = computed(() => getNetworkById(networkId.value))
const networkRules = computed(() => getNetworkRules(trafficRules.value, networkId.value))
const isXapiPluginRequired = computed(() => {
if (network.value === undefined) {
return false
}
const pool = getPoolById(network.value.$pool)
return !isNetworkRuleSupported(pool)
})
const orderedRules = ref<RankedTrafficRule[] | undefined>(undefined)
const {
run,
canRun: canEditTrafficRulesOrder,
isRunning: isEditingTrafficRulesOrder,
errorMessage: editTrafficRulesOrderErrorMessage,
} = useXoTrafficRuleEditOrderJob(networkRules, orderedRules, network)
const { open } = useOverlay({
component: () => import('@/modules/traffic-rules/components/drawer/TrafficRuleEditOrderDrawer.vue'),
events: {
onConfirm: (newOrderedRules: RankedTrafficRule[]) => {
orderedRules.value = newOrderedRules
run({ detached: true })
},
onCancel: true,
},
})
const props = reactiveComputed(() => ({
network: network.value,
rules: networkRules.value,
}))
function editTrafficRulesOrder() {
return open({ props })
}
return {
editTrafficRulesOrder,
canEditTrafficRulesOrder,
isEditingTrafficRulesOrder,
editTrafficRulesOrderErrorMessage,
isXapiPluginRequired,
}
}

View File

@@ -0,0 +1,7 @@
import { defineJobArg } from '@core/packages/job'
import type { TrafficRule } from '@vates/types'
export const orderedTrafficRulesArg = defineJobArg<TrafficRule[]>({
identify: false,
toArray: false,
})

View File

@@ -0,0 +1,76 @@
import { xoNetworkArg } from '@/modules/network/jobs/xo-network-args.ts'
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import type { FrontXoTask } from '@/modules/task/remote-resources/use-xo-task-collection.ts'
import { xoTrafficRulesArg } from '@/modules/traffic-rules/jobs/xo-traffic-rule-args.ts'
import { orderedTrafficRulesArg } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-order-args.ts'
import {
getTrafficRulePriorityUpdates,
isNetworkRuleSupported,
} from '@/modules/traffic-rules/utils/xo-traffic-rule.util.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'
const MIN_RULES_TO_EDIT_ORDER = 2
export const useXoTrafficRuleEditOrderJob = defineJob(
'traffic-rule.edit-order',
[xoTrafficRulesArg, orderedTrafficRulesArg, xoNetworkArg],
() => {
const { monitorTask } = useXoTaskUtils()
const { t } = useI18n()
const { getPoolById } = useXoPoolCollection()
return {
async run(networkRules: TrafficRule[], orderedRules: TrafficRule[]) {
const updates = getTrafficRulePriorityUpdates(orderedRules, networkRules)
if (updates === undefined) {
throw new JobError(t('job:traffic-rule-edit-order:no-free-priority'))
}
for (const { rule, priority } of updates) {
const scope = rule.type === 'network' ? 'networks' : 'vifs'
const endpoint = `plugins/sdn-controller/${scope}/${rule.sourceId}/actions/update_traffic_rule`
const { taskId } = await fetchPost<{ taskId: FrontXoTask['id'] }>(endpoint, {
oldRule: {
allow: rule.allow,
direction: rule.direction,
ipRange: rule.ipRange,
protocol: rule.protocol,
port: rule.port !== undefined ? Number(rule.port) : undefined,
priority: rule.priority,
},
newRule: { priority },
})
await monitorTask(taskId)
}
},
validate(isRunning, networkRules, _orderedRules, network: FrontXoNetwork | undefined) {
if (network === undefined) {
throw new JobError(t('job:arg:network-required'))
}
if (isRunning) {
throw new JobRunningError(t('job:edit:in-progress'))
}
if (networkRules.length < MIN_RULES_TO_EDIT_ORDER) {
throw new JobError(t('job:traffic-rule-edit-order:not-enough-rules', { n: MIN_RULES_TO_EDIT_ORDER }))
}
const pool = getPoolById(network.$pool)
if (!isNetworkRuleSupported(pool)) {
throw new JobError(t('traffic-rules:xapi-plugin-required'))
}
},
}
}
)

View File

@@ -0,0 +1,204 @@
import type { FrontXoNetwork } from '@/modules/network/remote-resources/use-xo-network-collection.ts'
import type { useXoPoolCollection } from '@/modules/pool/remote-resources/use-xo-pool-collection.ts'
import { useXoTrafficRuleEditOrderJob } from '@/modules/traffic-rules/jobs/xo-traffic-rule-edit-order.job.ts'
import type { useXoTaskUtils } from '@/shared/composables/xo-task-utils.composable.ts'
import type { fetchPost } from '@/shared/utils/fetch.util.ts'
import { t } from '@/test/i18n.ts'
import { mountComposable } from '@/test/mount-composable.ts'
import { SDN_CONTROLLER_OF_METHOD_KEY, type TrafficRule, type XoNetwork } from '@vates/types'
const { post, monitorTask, pool } = vi.hoisted(() => ({
post: vi.fn(),
monitorTask: vi.fn(),
pool: { otherConfig: {} as Record<string, string> },
}))
vi.mock(import('@/shared/utils/fetch.util.ts'), () => ({
fetchPost: post as unknown as typeof fetchPost,
}))
// The task collection would open an SSE subscription
vi.mock(import('@/shared/composables/xo-task-utils.composable.ts'), () => ({
useXoTaskUtils: (() => ({ monitorTask })) as unknown as typeof useXoTaskUtils,
}))
vi.mock(import('@/modules/pool/remote-resources/use-xo-pool-collection.ts'), () => ({
useXoPoolCollection: (() => ({
getPoolById: () => pool,
})) as unknown as typeof useXoPoolCollection,
}))
beforeEach(() => {
vi.restoreAllMocks()
post.mockReset()
monitorTask.mockReset()
post.mockImplementation(async (url: string) => ({ taskId: `task-of-${url}` }))
monitorTask.mockResolvedValue(undefined)
pool.otherConfig = { [SDN_CONTROLLER_OF_METHOD_KEY]: 'xapi-plugin' }
})
const NETWORK_ID = 'network-1' as XoNetwork['id']
const NETWORK = { id: NETWORK_ID, $pool: 'pool-1' } as FrontXoNetwork
const ENDPOINT = `plugins/sdn-controller/networks/${NETWORK_ID}/actions/update_traffic_rule`
function createRule(id: string, priority: number | undefined): TrafficRule {
return {
id,
allow: true,
protocol: 'TCP',
ipRange: '10.0.0.0/8',
direction: 'to',
priority,
networkId: NETWORK_ID,
type: 'network',
sourceId: NETWORK_ID,
}
}
function mountEditOrderJob(
networkRules: TrafficRule[],
orderedRules: TrafficRule[] | undefined,
network: FrontXoNetwork | undefined = NETWORK
) {
return mountComposable(() =>
useXoTrafficRuleEditOrderJob(
() => networkRules,
() => orderedRules,
() => network
)
).wrapper.vm
}
function getNewPriorities() {
return post.mock.calls.map(([, body]) => body.newRule.priority)
}
describe('run', () => {
it('updates nothing when the order is unchanged', async () => {
const rules = [createRule('a', 300), createRule('b', 200), createRule('c', 100)]
await mountEditOrderJob(rules, rules).run()
expect(post).not.toHaveBeenCalled()
})
it('only updates the moved rule, with a priority above the rules it now precedes', async () => {
const [firstRule, secondRule, thirdRule] = [createRule('a', 300), createRule('b', 200), createRule('c', 100)]
await mountEditOrderJob([firstRule, secondRule, thirdRule], [thirdRule, firstRule, secondRule]).run()
expect(post.mock.calls).toEqual([
[ENDPOINT, { oldRule: expect.objectContaining({ priority: 100 }), newRule: { priority: expect.any(Number) } }],
])
expect(getNewPriorities()[0]).toBeGreaterThan(300)
})
it('never gives a priority a rule of the network already has', async () => {
const [firstRule, secondRule, thirdRule, fourthRule] = [
createRule('a', 400),
createRule('b', 300),
createRule('c', 200),
createRule('d', 100),
]
await mountEditOrderJob(
[firstRule, secondRule, thirdRule, fourthRule],
[secondRule, fourthRule, firstRule, thirdRule]
).run()
const newPriorities = getNewPriorities()
newPriorities.forEach(priority => expect([400, 300, 200, 100]).not.toContain(priority))
})
it('never gives a priority a rule of the network missing from the order already has', async () => {
const [firstRule, secondRule, thirdRule, fourthRule] = [
createRule('a', 5),
createRule('b', 2),
createRule('c', 0),
createRule('d', 1),
]
// added on the network while the order was being edited
const addedRule = createRule('x', 3)
await mountEditOrderJob(
[firstRule, secondRule, thirdRule, fourthRule, addedRule],
[firstRule, thirdRule, secondRule, fourthRule]
).run()
getNewPriorities().forEach(priority => expect(priority).not.toBe(3))
})
it('renumbers all the rules when there is no room between the kept ones', async () => {
const [firstRule, secondRule] = [createRule('a', 1), createRule('b', 0)]
await mountEditOrderJob([firstRule, secondRule], [secondRule, firstRule]).run()
const [firstPriority, secondPriority] = getNewPriorities()
expect(post.mock.calls.map(([, body]) => body.oldRule.priority)).toEqual([0, 1])
expect(firstPriority).toBeGreaterThan(secondPriority)
})
it('gives a priority to the rules that have none', async () => {
const [firstRule, secondRule] = [createRule('a', undefined), createRule('b', undefined)]
await mountEditOrderJob([firstRule, secondRule], [secondRule, firstRule]).run()
const [firstPriority, secondPriority] = getNewPriorities()
expect(firstPriority).toBeGreaterThan(secondPriority)
})
it('applies the updates one at a time and stops at the first failure', async () => {
const [firstRule, secondRule] = [createRule('a', 1), createRule('b', 0)]
const error = new Error('Update failed')
monitorTask.mockRejectedValueOnce(error)
await expect(mountEditOrderJob([firstRule, secondRule], [secondRule, firstRule]).run()).rejects.toBe(error)
expect(post).toHaveBeenCalledTimes(1)
})
})
describe('validate', () => {
it('can run with at least 2 rules on a network using the XAPI plugin', () => {
const rules = [createRule('a', 200), createRule('b', 100)]
expect(mountEditOrderJob(rules, undefined).canRun).toBe(true)
})
it('cannot run without a network', () => {
const rules = [createRule('a', 200), createRule('b', 100)]
const job = mountComposable(() =>
useXoTrafficRuleEditOrderJob(
() => rules,
() => undefined,
() => undefined
)
).wrapper.vm
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(t('job:arg:network-required'))
})
it('cannot run with less than 2 rules', () => {
const job = mountEditOrderJob([createRule('a', 100)], undefined)
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(t('job:traffic-rule-edit-order:not-enough-rules', { n: 2 }))
})
it('cannot run without the XAPI plugin', () => {
pool.otherConfig = {}
const job = mountEditOrderJob([createRule('a', 200), createRule('b', 100)], undefined)
expect(job.canRun).toBe(false)
expect(job.errorMessage).toBe(t('traffic-rules:xapi-plugin-required'))
})
})

View File

@@ -6,6 +6,15 @@ import { SDN_CONTROLLER_OF_METHOD_KEY, type TrafficRule } from '@vates/types'
// OpenFlow default, applied to rules stored without a priority
const DEFAULT_TRAFFIC_RULE_PRIORITY = 32768
const MIN_TRAFFIC_RULE_PRIORITY = 0
const MAX_TRAFFIC_RULE_PRIORITY = 65535
type TrafficRulePriorityUpdate = {
rule: TrafficRule
priority: number
}
export function isNetworkRuleSupported(pool: FrontXoPool | undefined): boolean {
const ofMethod = pool?.otherConfig[SDN_CONTROLLER_OF_METHOD_KEY]
@@ -67,6 +76,118 @@ export function rankTrafficRules(
})
}
function getKeptRuleIndexes(orderedRules: TrafficRule[]): number[] {
const chainLengths: number[] = []
const chainPreviousIndexes: (number | undefined)[] = []
let longestChainEndIndex: number | undefined
orderedRules.forEach((rule, index) => {
chainLengths[index] = 0
chainPreviousIndexes[index] = undefined
if (rule.priority === undefined) {
return
}
chainLengths[index] = 1
for (let previousIndex = 0; previousIndex < index; previousIndex++) {
const previousRule = orderedRules[previousIndex]
if (
previousRule.priority !== undefined &&
previousRule.priority > rule.priority &&
chainLengths[previousIndex] + 1 > chainLengths[index]
) {
chainLengths[index] = chainLengths[previousIndex] + 1
chainPreviousIndexes[index] = previousIndex
}
}
if (longestChainEndIndex === undefined || chainLengths[index] > chainLengths[longestChainEndIndex]) {
longestChainEndIndex = index
}
})
const keptIndexes: number[] = []
let index = longestChainEndIndex
while (index !== undefined) {
keptIndexes.unshift(index)
index = chainPreviousIndexes[index]
}
return keptIndexes
}
// The other rules get a priority between those of their kept neighbours, spread out to leave room for later moves.
// No new priority is one a rule of the network already has, so the updates never conflict with the other rules.
function getPriorityUpdatesBetweenKeptRules(
orderedRules: TrafficRule[],
keptIndexes: number[],
takenPriorities: Set<number | undefined>
): TrafficRulePriorityUpdate[] | undefined {
const boundIndexes = [-1, ...keptIndexes, orderedRules.length]
const updates: TrafficRulePriorityUpdate[] = []
for (let intervalIndex = 0; intervalIndex < boundIndexes.length - 1; intervalIndex++) {
const upperIndex = boundIndexes[intervalIndex]
const lowerIndex = boundIndexes[intervalIndex + 1]
const movedRules = orderedRules.slice(upperIndex + 1, lowerIndex)
if (movedRules.length === 0) {
continue
}
let upperPriority = MAX_TRAFFIC_RULE_PRIORITY + 1
if (upperIndex !== -1) {
upperPriority = getTrafficRulePriority(orderedRules[upperIndex])
}
let lowerPriority = MIN_TRAFFIC_RULE_PRIORITY - 1
if (lowerIndex !== orderedRules.length) {
lowerPriority = getTrafficRulePriority(orderedRules[lowerIndex])
}
const freePriorities: number[] = []
for (let priority = upperPriority - 1; priority > lowerPriority; priority--) {
if (!takenPriorities.has(priority)) {
freePriorities.push(priority)
}
}
if (freePriorities.length <= movedRules.length) {
return undefined
}
const step = freePriorities.length / (movedRules.length + 1)
movedRules.forEach((rule, index) => {
updates.push({ rule, priority: freePriorities[Math.floor((index + 1) * step)] })
})
}
return updates
}
export function getTrafficRulePriorityUpdates(
orderedRules: TrafficRule[],
networkRules: TrafficRule[]
): TrafficRulePriorityUpdate[] | undefined {
const takenPriorities = new Set(networkRules.map(rule => rule.priority))
const updates = getPriorityUpdatesBetweenKeptRules(orderedRules, getKeptRuleIndexes(orderedRules), takenPriorities)
if (updates !== undefined) {
return updates
}
return getPriorityUpdatesBetweenKeptRules(orderedRules, [], takenPriorities)
}
export function getOrdinalSuffix(suffixesMessage: string, locale: string, position: number): string | undefined {
const suffixes = new Map<string, string>()